From f2ee4d4d7c4fc9148fd622c0b15ecade561888ed Mon Sep 17 00:00:00 2001 From: JasonC Date: Fri, 28 Nov 2025 22:24:41 -0500 Subject: [PATCH 1/5] Extend preview panel multi-selection with shared tag editing and update tests --- .../controllers/preview_panel_controller.py | 4 ++ src/tagstudio/qt/mixed/field_containers.py | 56 ++++++++++++++++++- src/tagstudio/qt/views/preview_panel_view.py | 10 ++-- src/tagstudio/resources/translations/en.json | 2 +- tests/qt/test_field_containers.py | 8 +-- tests/qt/test_preview_panel.py | 8 +++ 6 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0cf666198..ba447f52b 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -40,8 +40,12 @@ def _add_field_to_selected(self, field_list: list[QListWidgetItem]): self._fields.add_field_to_selected(field_list) if len(self._selected) == 1: self._fields.update_from_entry(self._selected[0]) + elif len(self._selected) > 1: + self._fields.update_from_entries(self._selected) def _add_tag_to_selected(self, tag_id: int): self._fields.add_tags_to_selected(tag_id) if len(self._selected) == 1: self._fields.update_from_entry(self._selected[0]) + elif len(self._selected) > 1: + self._fields.update_from_entries(self._selected) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index ae8df9107..7d2e7724f 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -111,6 +111,51 @@ def update_from_entry(self, entry_id: int, update_badges: bool = True): 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): + """Update tags and fields from multiple Entry sources, showing shared tags.""" + logger.warning("[FieldContainers] Updating Multiple Selection", entry_ids=entry_ids) + + entries = list(self.lib.get_entries_full(entry_ids)) + if not entries: + self.cached_entries = [] + self.hide_containers() + return + + self.cached_entries = entries + + shared_tags = self._get_shared_tags(entries) + shared_fields = self._get_shared_fields(entries) + + self.update_granular(shared_tags, shared_fields, update_badges) + + 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 _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: + """Get fields that are present in all entries with the same value.""" + if not entries: + return [] + + shared_fields = [] + first_entry_fields = entries[0].fields + + for field in first_entry_fields: + if all( + any(f.type.id == field.type.id and f.value == field.value for f in entry.fields) + for entry in entries[1:] + ): + shared_fields.append(field) + + return shared_fields + def update_granular( self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True ): @@ -438,9 +483,14 @@ def write_tag_container( 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)) - ) + 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) + + inner_widget.on_update.connect(update_callback) else: text = "Mixed Data" inner_widget = TextWidget("Mixed Tags", text) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 5ae7004cd..1497a1e19 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -158,6 +158,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = unwrap(self.lib.library_dir) / entry.path + self.add_buttons_enabled = True + if update_preview: stats: FileAttributeData = self.__thumb.display_file(filepath) self.__file_attrs.update_stats(filepath, stats) @@ -166,20 +168,16 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self._set_selection_callback() - self.add_buttons_enabled = True - # Multiple Selected Items elif len(selected) > 1: - # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + self.add_buttons_enabled = True self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self._fields.hide_containers() # TODO: Allow for mixed editing + self._fields.update_from_entries(selected) self._set_selection_callback() - self.add_buttons_enabled = True - except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) traceback.print_exc() diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 8b84af737..39c5fbf04 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -248,7 +248,7 @@ "namespace.new.button": "New Namespace", "namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!", "preview.ignored": "Ignored", - "preview.multiple_selection": "{count} Items Selected", + "preview.multiple_selection": "{count} Items Selected
Showing tags shared by all selected entries", "preview.no_selection": "No Items Selected", "preview.unlinked": "Unlinked", "select.add_tag_to_selected": "Add Tag to Selected", diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 2b9921146..3e8483b60 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -36,8 +36,6 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu def test_update_selection_multiple(qt_driver: QtDriver, library: Library): - # TODO: Implement mixed field editing. Currently these containers will be hidden, - # same as the empty selection behavior. panel = PreviewPanel(library, qt_driver) # Select the multiple entries @@ -45,9 +43,9 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): qt_driver.toggle_item_selection(2, append=True, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should show mixed field editing - for container in panel.field_containers_widget.containers: - assert container.isHidden() + # Panel should enable UI that allows for entry modification and cache all selected entries + assert panel.add_buttons_enabled + assert len(panel.field_containers_widget.cached_entries) == 2 def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 12282c9b2..08056d262 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -6,6 +6,7 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.translations import Translations from tagstudio.qt.ts_qt import QtDriver @@ -42,3 +43,10 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): # Panel should enable UI that allows for entry modification assert panel.add_buttons_enabled + + # File attributes should indicate multiple selection and shared tags + attrs = panel._file_attributes_widget + expected_label = Translations.format( + "preview.multiple_selection", count=len(qt_driver.selected) + ) + assert attrs.file_label.text() == expected_label From ef2c1e3de7dfe96abf008d07ce094368df1a0f13 Mon Sep 17 00:00:00 2001 From: JasonC Date: Sat, 29 Nov 2025 18:09:24 -0500 Subject: [PATCH 2/5] Fix preview panel race condition and add mixed tag display for multi-selection --- .../qt/controllers/tag_box_controller.py | 33 +++++++ src/tagstudio/qt/mixed/field_containers.py | 93 +++++++++++++------ src/tagstudio/qt/views/preview_thumb_view.py | 12 ++- src/tagstudio/resources/translations/en.json | 1 + 4 files changed, 109 insertions(+), 30 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 2a5865d8b..19672b674 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -25,6 +25,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) @@ -33,6 +34,38 @@ 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 non-shared (grayed out).""" + self.__mixed_only = value + + def set_tags(self, tags): # type: ignore[override] + """Render tags; optionally gray out those that are not shared across entries.""" + tags_ = list(tags) + + # When mixed_only is set, all tags in this widget are considered non-shared. + shared_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: + shared_tag_ids.add(tag_id) + + super().set_tags(tags_) + + # Gray out tags that are not shared across all selected entries. + from tagstudio.qt.mixed.tag_widget import TagWidget # local import to avoid cycles + + layout = getattr(self, "_TagBoxWidgetView__root_layout", None) + if layout is not None: + for i in range(layout.count()): + item = layout.itemAt(i) + widget = item.widget() + if isinstance(widget, TagWidget) and widget.tag: + if self.__mixed_only or widget.tag.id not in shared_tag_ids: + widget.setEnabled(False) + @override def _on_click(self, tag: Tag) -> None: # type: ignore[misc] match self.__driver.settings.tag_click_action: diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 7d2e7724f..d2b48650c 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -124,9 +124,39 @@ def update_from_entries(self, entry_ids: list[int], update_badges: bool = True): self.cached_entries = entries shared_tags = self._get_shared_tags(entries) - shared_fields = self._get_shared_fields(entries) - self.update_granular(shared_tags, shared_fields, update_badges) + # Compute shared and mixed fields by type id and value. + all_fields_by_type: dict[int, list[BaseField]] = {} + for entry in entries: + for field in entry.fields: + all_fields_by_type.setdefault(field.type.id, []).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]) + + all_fields: list[BaseField] = shared_fields + mixed_fields + mixed_field_type_ids: set[int] = {f.type.id for f in mixed_fields} + + self.update_granular( + shared_tags, + all_fields, + update_badges, + mixed_field_type_ids=mixed_field_type_ids if mixed_field_type_ids else None, + ) + + # Add a separate container for tags that aren't shared across all entries. + all_tags: set[Tag] = set() + for entry in entries: + all_tags.update(entry.tags) + mixed_tags: set[Tag] = all_tags - shared_tags + if mixed_tags: + index = len(self.containers) + self.write_tag_container(index, tags=mixed_tags, category_tag=None, is_mixed=True) def _get_shared_tags(self, entries: list[Entry]) -> set[Tag]: """Get tags that are present in all entries.""" @@ -157,7 +187,11 @@ def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: return shared_fields def update_granular( - self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True + self, + entry_tags: set[Tag], + entry_fields: list[BaseField], + update_badges: bool = True, + mixed_field_type_ids: set[int] | None = None, ): """Individually update elements of the item preview.""" container_len: int = len(entry_fields) @@ -176,7 +210,8 @@ def update_granular( # Write field container(s) for index, field in enumerate(entry_fields, start=container_index): - self.write_container(index, field, is_mixed=False) + is_mixed = mixed_field_type_ids is not None and field.type.id in mixed_field_type_ids + self.write_container(index, field, is_mixed=is_mixed) # Hide leftover container(s) if len(self.containers) > container_len: @@ -467,34 +502,36 @@ def write_tag_container( container.set_title("Tags" if not category_tag else category_tag.name) container.set_inline(False) - if not is_mixed: - inner_widget = container.get_inner_widget() + inner_widget = container.get_inner_widget() + + if isinstance(inner_widget, TagBoxWidget): + with catch_warnings(record=True): + inner_widget.on_update.disconnect() + else: + inner_widget = TagBoxWidget( + "Tags", + self.driver, + ) + container.set_inner_widget(inner_widget) + + # For mixed tag containers, mark the widget so it can gray out all tags. + if is_mixed: + inner_widget.set_mixed_only(True) + container.set_title(Translations["preview.partial_tags"]) + else: + inner_widget.set_mixed_only(False) - if isinstance(inner_widget, TagBoxWidget): - with catch_warnings(record=True): - inner_widget.on_update.disconnect() + 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: - inner_widget = TagBoxWidget( - "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) - - 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) + entry_ids = [e.id for e in self.cached_entries] + self.update_from_entries(entry_ids, update_badges=True) - inner_widget.on_update.connect(update_callback) - else: - text = "Mixed Data" - inner_widget = TextWidget("Mixed Tags", text) - container.set_inner_widget(inner_widget) + inner_widget.on_update.connect(update_callback) container.set_edit_callback() container.set_remove_callback() diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index e50509ad7..33bfd42e0 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -37,12 +37,14 @@ class PreviewThumbView(QWidget): __filepath: Path | None __rendered_res: tuple[int, int] + __render_cutoff: float def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.__img_button_size = (266, 266) self.__image_ratio = 1.0 + self.__render_cutoff = 0.0 self.__image_layout = QStackedLayout(self) self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -126,8 +128,11 @@ def __media_player_video_changed_callback(self, video: bool) -> None: self.__update_image_size((self.size().width(), self.size().height())) def __thumb_renderer_updated_callback( - self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path + self, timestamp: float, img: QPixmap, _size: QSize, _path: Path ) -> None: + # Ignore outdated renders if a newer selection has been requested. + if timestamp < self.__render_cutoff: + return self.__button_wrapper.setIcon(img) def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: @@ -213,8 +218,11 @@ def __render_thumb(self, filepath: Path) -> None: math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), ) + timestamp = time.time() + self.__render_cutoff = timestamp + self.__thumb_renderer.render( - time.time(), + timestamp, filepath, self.__rendered_res, self.devicePixelRatio(), diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 39c5fbf04..94d41ba31 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -250,6 +250,7 @@ "preview.ignored": "Ignored", "preview.multiple_selection": "{count} Items Selected
Showing tags shared by all selected entries", "preview.no_selection": "No Items Selected", + "preview.partial_tags": "Tags (Some Entries)", "preview.unlinked": "Unlinked", "select.add_tag_to_selected": "Add Tag to Selected", "select.all": "Select All", From 46ad7b86ebbc5eb9d779811cc4ec6ea3a25a6e7c Mon Sep 17 00:00:00 2001 From: JasonC Date: Mon, 23 Mar 2026 18:59:49 -0400 Subject: [PATCH 3/5] fix bulk selection preview for partial tags and fields --- .../controllers/preview_panel_controller.py | 10 +- .../qt/controllers/tag_box_controller.py | 30 ++-- src/tagstudio/qt/mixed/field_containers.py | 153 ++++++++++++------ src/tagstudio/qt/mixed/tag_widget.py | 18 ++- src/tagstudio/qt/views/preview_panel_view.py | 4 + src/tagstudio/qt/views/tag_box_view.py | 3 +- src/tagstudio/resources/translations/en.json | 4 +- tests/qt/test_field_containers.py | 24 +++ tests/qt/test_preview_panel.py | 38 +++++ 9 files changed, 208 insertions(+), 76 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index ba447f52b..ba62c1fad 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -38,14 +38,8 @@ def _set_selection_callback(self): def _add_field_to_selected(self, field_list: list[QListWidgetItem]): self._fields.add_field_to_selected(field_list) - if len(self._selected) == 1: - self._fields.update_from_entry(self._selected[0]) - elif len(self._selected) > 1: - self._fields.update_from_entries(self._selected) + self.refresh_selection(update_preview=False) def _add_tag_to_selected(self, tag_id: int): self._fields.add_tags_to_selected(tag_id) - if len(self._selected) == 1: - self._fields.update_from_entry(self._selected[0]) - elif len(self._selected) > 1: - self._fields.update_from_entries(self._selected) + self.refresh_selection(update_preview=False) diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 19672b674..70bfc6162 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -35,36 +35,26 @@ 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 non-shared (grayed out).""" + """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; optionally gray out those that are not shared across entries.""" + """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 non-shared. - shared_tag_ids: set[int] = set() + # 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: - shared_tag_ids.add(tag_id) - - super().set_tags(tags_) - - # Gray out tags that are not shared across all selected entries. - from tagstudio.qt.mixed.tag_widget import TagWidget # local import to avoid cycles - - layout = getattr(self, "_TagBoxWidgetView__root_layout", None) - if layout is not None: - for i in range(layout.count()): - item = layout.itemAt(i) - widget = item.widget() - if isinstance(widget, TagWidget) and widget.tag: - if self.__mixed_only or widget.tag.id not in shared_tag_ids: - widget.setEnabled(False) + 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: # type: ignore[misc] diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index d2b48650c..febc3c8e2 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -14,6 +14,7 @@ from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QFrame, + QGraphicsOpacityEffect, QHBoxLayout, QMessageBox, QScrollArea, @@ -105,17 +106,22 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_from_entry(self, entry_id: int, update_badges: bool = True): """Update tags and fields from a single Entry source.""" - logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - - entry = unwrap(self.lib.get_entry_full(entry_id)) - self.cached_entries = [entry] - self.update_granular(entry.tags, entry.fields, update_badges) + self.update_from_selection(entry_id, update_badges) def update_from_entries(self, entry_ids: list[int], update_badges: bool = True): """Update tags and fields from multiple Entry sources, showing shared tags.""" - logger.warning("[FieldContainers] Updating Multiple Selection", entry_ids=entry_ids) + self.update_from_selection(entry_ids, update_badges) + + def update_from_selection(self, entry_ids: int | list[int], update_badges: bool = True): + """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)) - entries = list(self.lib.get_entries_full(entry_ids)) if not entries: self.cached_entries = [] self.hide_containers() @@ -123,40 +129,40 @@ def update_from_entries(self, entry_ids: list[int], update_badges: bool = True): self.cached_entries = entries - shared_tags = self._get_shared_tags(entries) - - # Compute shared and mixed fields by type id and value. - all_fields_by_type: dict[int, list[BaseField]] = {} - for entry in entries: - for field in entry.fields: - all_fields_by_type.setdefault(field.type.id, []).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]) + if len(entries) == 1: + entry = entries[0] + self.update_granular(entry.tags, entry.fields, update_badges) + return - all_fields: list[BaseField] = shared_fields + mixed_fields - mixed_field_type_ids: set[int] = {f.type.id for f in mixed_fields} + 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) - self.update_granular( + next_index = self.update_granular( shared_tags, - all_fields, + shared_fields, update_badges, - mixed_field_type_ids=mixed_field_type_ids if mixed_field_type_ids else None, + hide_leftovers=False, ) - # Add a separate container for tags that aren't shared across all entries. - all_tags: set[Tag] = set() - for entry in entries: - all_tags.update(entry.tags) - mixed_tags: set[Tag] = all_tags - shared_tags - if mixed_tags: - index = len(self.containers) - self.write_tag_container(index, tags=mixed_tags, category_tag=None, is_mixed=True) + 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_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.""" @@ -179,20 +185,38 @@ def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: for field in first_entry_fields: if all( - any(f.type.id == field.type.id and f.value == field.value for f in entry.fields) + any(f.type_key == field.type_key and f.value == field.value for f in entry.fields) for entry in entries[1:] ): shared_fields.append(field) return shared_fields + 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[str, list[BaseField]] = {} + for entry in entries: + for field in entry.fields: + all_fields_by_type.setdefault(field.type_key, []).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, - mixed_field_type_ids: set[int] | None = None, - ): + *, + hide_leftovers: bool = True, + ) -> int: """Individually update elements of the item preview.""" container_len: int = len(entry_fields) container_index = 0 @@ -210,14 +234,13 @@ def update_granular( # Write field container(s) for index, field in enumerate(entry_fields, start=container_index): - is_mixed = mixed_field_type_ids is not None and field.type.id in mixed_field_type_ids - self.write_container(index, field, is_mixed=is_mixed) + self.write_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): """Visually add or remove a tag from the item preview without needing to query the db.""" @@ -237,6 +260,12 @@ def hide_containers(self): 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. @@ -320,6 +349,15 @@ def add_tags_to_selected(self, tags: int | list[int]): ) self.driver.emit_badge_signals(tags, emit_on_absent=False) + def set_container_partial(self, container: FieldContainer, partial: bool) -> None: + """Apply a visual partial-selection treatment to a container.""" + if partial: + effect = QGraphicsOpacityEffect(container) + effect.setOpacity(0.7) + container.setGraphicsEffect(effect) + else: + container.setGraphicsEffect(None) + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """Update/Create data for a FieldContainer. @@ -338,6 +376,11 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: container = self.containers[index] + self.set_container_partial(container, is_mixed) + container.set_copy_callback() + container.set_edit_callback() + container.set_remove_callback() + if field.type.type == FieldTypeEnum.TEXT_LINE: container.set_title(field.type.name) container.set_inline(False) @@ -478,6 +521,26 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): 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, False) + container.set_title(title) + container.set_inline(False) + container.set_inner_widget(TextWidget(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 ): @@ -499,6 +562,7 @@ def write_tag_container( else: container = self.containers[index] + self.set_container_partial(container, is_mixed) container.set_title("Tags" if not category_tag else category_tag.name) container.set_inline(False) @@ -517,7 +581,6 @@ def write_tag_container( # For mixed tag containers, mark the widget so it can gray out all tags. if is_mixed: inner_widget.set_mixed_only(True) - container.set_title(Translations["preview.partial_tags"]) else: inner_widget.set_mixed_only(False) diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 85b62c4e0..507ae79c1 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -9,7 +9,14 @@ import structlog from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics -from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag @@ -273,6 +280,15 @@ def set_tag(self, tag: Tag | None) -> None: def set_has_remove(self, has_remove: bool): self.has_remove = has_remove + def set_partial(self, partial: bool) -> None: + """Visually dim tags that are only present on part of the selection.""" + if partial: + effect = QGraphicsOpacityEffect(self) + effect.setOpacity(0.55) + self.setGraphicsEffect(effect) + else: + self.setGraphicsEffect(None) + @override def enterEvent(self, event: QEnterEvent) -> None: if self.has_remove: diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 1497a1e19..9c0610745 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -132,6 +132,10 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() + def refresh_selection(self, update_preview: bool = False) -> None: + """Refresh the current selection without requiring the caller to re-read it.""" + self.set_selection(self._selected, update_preview=update_preview) + def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index bf24a88cf..6ab4e0052 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -31,7 +31,7 @@ def __init__(self, title: str, driver: "QtDriver") -> None: self.__root_layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.__root_layout) - def set_tags(self, tags: Iterable[Tag]) -> None: + def set_tags(self, tags: Iterable[Tag], partial_tag_ids: set[int] | None = None) -> None: tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag)) logger.info("[TagBoxWidget] Tags:", tags=tags) while self.__root_layout.itemAt(0): @@ -39,6 +39,7 @@ def set_tags(self, tags: Iterable[Tag]) -> None: for tag in tags_: tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True) + tag_widget.set_partial(bool(partial_tag_ids and tag.id in partial_tag_ids)) tag_widget.on_click.connect(lambda t=tag: self._on_click(t)) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 94d41ba31..1159bd4f0 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -248,8 +248,10 @@ "namespace.new.button": "New Namespace", "namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!", "preview.ignored": "Ignored", - "preview.multiple_selection": "{count} Items Selected
Showing tags shared by all selected entries", + "preview.multiple_selection": "{count} Items Selected
Showing tags and fields shared by all selected entries", "preview.no_selection": "No Items Selected", + "preview.partial_section": "Tags and Fields Not On Every Selected Item", + "preview.partial_section_body": "_These are only present on some selected entries._", "preview.partial_tags": "Tags (Some Entries)", "preview.unlinked": "Unlinked", "select.add_tag_to_selected": "Add Tag to Selected", diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 3e8483b60..00da8cc12 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -7,6 +7,7 @@ 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 +from tagstudio.qt.translations import Translations from tagstudio.qt.ts_qt import QtDriver @@ -183,3 +184,26 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: assert container.title != "

Tags

" case _: pass + + +def test_multi_selection_mixed_section_resets_on_single_selection( + qt_driver: QtDriver, library: Library +): + panel = PreviewPanel(library, qt_driver) + field_containers = panel.field_containers_widget + + field_containers.update_from_entries([1, 2]) + + container_titles = [c.title for c in field_containers.containers] + assert f"

{Translations['preview.partial_section']}

" in container_titles + assert "

Tags

" in container_titles + assert "

Title

" in container_titles + assert [entry.id for entry in field_containers.cached_entries] == [1, 2] + + field_containers.update_from_entry(1) + + entry = unwrap(library.get_entry_full(1)) + active_container_count = len(field_containers.get_tag_categories(entry.tags)) + len(entry.fields) + active_titles = [field_containers.containers[i].title for i in range(active_container_count)] + assert f"

{Translations['preview.partial_section']}

" not in active_titles + assert [cached_entry.id for cached_entry in field_containers.cached_entries] == [1] diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 08056d262..cc23ea6d9 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -3,6 +3,11 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from unittest.mock import Mock + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QListWidgetItem + from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel @@ -50,3 +55,36 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): "preview.multiple_selection", count=len(qt_driver.selected) ) assert attrs.file_label.text() == expected_label + + +def test_add_field_to_selection_multiple_refreshes(qt_driver: QtDriver, library: Library): + panel = PreviewPanel(library, qt_driver) + + # The add-field callback uses the driver's current selection, so seed it directly here. + qt_driver.main_window.thumb_layout._selected = {1: 0, 2: 1} + panel.set_selection([1, 2], update_preview=False) + refresh_selection_spy = Mock(wraps=panel.refresh_selection) + panel.refresh_selection = refresh_selection_spy + + selected_entries = list(library.get_entries_full([1, 2])) + existing_field_keys = {field.type_key for entry in selected_entries for field in entry.fields} + field_type = next( + value_type + for value_type in library.field_types.values() + if value_type.key not in existing_field_keys + ) + + item = QListWidgetItem(f"{field_type.name} ({field_type.type.value})") + item.setData(Qt.ItemDataRole.UserRole, field_type.key) + + panel._add_field_to_selected([item]) + refresh_selection_spy.assert_called_once_with(update_preview=False) + + refreshed_entries = list(library.get_entries_full([1, 2])) + assert all( + any(field.type_key == field_type.key for field in entry.fields) for entry in refreshed_entries + ) + assert all( + any(field.type_key == field_type.key for field in entry.fields) + for entry in panel.field_containers_widget.cached_entries + ) From fd21646885c3f9a1a7872ac0c7e4d492ff0dd52d Mon Sep 17 00:00:00 2001 From: JasonC Date: Sun, 3 May 2026 10:16:35 -0400 Subject: [PATCH 4/5] improve tests --- tests/qt/test_field_containers.py | 8 ++++++-- tests/qt/test_preview_panel.py | 11 +++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 00da8cc12..05aee1504 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -192,7 +192,9 @@ def test_multi_selection_mixed_section_resets_on_single_selection( panel = PreviewPanel(library, qt_driver) field_containers = panel.field_containers_widget - field_containers.update_from_entries([1, 2]) + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + panel.set_selection(qt_driver.selected) container_titles = [c.title for c in field_containers.containers] assert f"

{Translations['preview.partial_section']}

" in container_titles @@ -200,7 +202,9 @@ def test_multi_selection_mixed_section_resets_on_single_selection( assert "

Title

" in container_titles assert [entry.id for entry in field_containers.cached_entries] == [1, 2] - field_containers.update_from_entry(1) + # Switch back to single selection — the partial section should disappear + qt_driver.toggle_item_selection(1, append=False, bridge=False) + panel.set_selection(qt_driver.selected) entry = unwrap(library.get_entry_full(1)) active_container_count = len(field_containers.get_tag_categories(entry.tags)) + len(entry.fields) diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index cc23ea6d9..7e0da81f7 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -3,8 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from unittest.mock import Mock - from PySide6.QtCore import Qt from PySide6.QtWidgets import QListWidgetItem @@ -60,11 +58,9 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): def test_add_field_to_selection_multiple_refreshes(qt_driver: QtDriver, library: Library): panel = PreviewPanel(library, qt_driver) - # The add-field callback uses the driver's current selection, so seed it directly here. - qt_driver.main_window.thumb_layout._selected = {1: 0, 2: 1} - panel.set_selection([1, 2], update_preview=False) - refresh_selection_spy = Mock(wraps=panel.refresh_selection) - panel.refresh_selection = refresh_selection_spy + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + panel.set_selection(qt_driver.selected, update_preview=False) selected_entries = list(library.get_entries_full([1, 2])) existing_field_keys = {field.type_key for entry in selected_entries for field in entry.fields} @@ -78,7 +74,6 @@ def test_add_field_to_selection_multiple_refreshes(qt_driver: QtDriver, library: item.setData(Qt.ItemDataRole.UserRole, field_type.key) panel._add_field_to_selected([item]) - refresh_selection_spy.assert_called_once_with(update_preview=False) refreshed_entries = list(library.get_entries_full([1, 2])) assert all( From 58a6b324556d464aba0a20a619fe4bd5eee16e2d Mon Sep 17 00:00:00 2001 From: JasonC Date: Thu, 28 May 2026 22:12:14 -0400 Subject: [PATCH 5/5] Merge origin/main into bulk-tag-editing, resolve field_containers conflict --- .editorconfig | 22 + .git-blame-ignore-revs | 4 + .github/FUNDING.yml | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 6 +- .github/ISSUE_TEMPLATE/feature_request.yml | 6 +- .github/PULL_REQUEST_TEMPLATE.md | 27 +- .github/workflows/publish_docs.yaml | 6 +- .github/workflows/{mypy.yaml => pyright.yaml} | 23 +- .github/workflows/pytest.yaml | 11 +- .github/workflows/release.yml | 37 +- .github/workflows/reuse.yaml | 12 + .github/workflows/ruff.yaml | 2 + .pre-commit-config.yaml | 6 +- .prettierignore | 5 + .prettierrc.toml | 10 + LICENSES/Apache-2.0.txt | 202 +++ LICENSES/CC-BY-SA-4.0.txt | 428 ++++++ LICENSES/CC0-1.0.txt | 121 ++ LICENSES/GPL-3.0-only.txt | 675 +++++++++ LICENSES/GPL-3.0-or-later.txt | 1 + LICENSES/MIT.txt | 19 + LICENSES/OFL-1.1.txt | 97 ++ README.md | 111 +- REUSE.toml | 49 + contrib/.vscode/launch.json | 5 +- docs/changelog.md | 1331 +++++++++-------- docs/colors.md | 4 + docs/contributing.md | 206 ++- docs/developing.md | 72 +- docs/entries.md | 58 +- docs/fields.md | 10 +- docs/help/ffmpeg.md | 5 +- docs/ignore.md | 21 +- docs/index.md | 41 +- docs/install.md | 72 +- docs/libraries.md | 4 + docs/library-changes.md | 137 +- docs/macros.md | 4 + docs/preview-support.md | 58 +- docs/roadmap.md | 439 +++--- docs/search.md | 56 +- docs/style.md | 65 +- docs/stylesheets/extra.css | 10 +- docs/stylesheets/home.css | 4 + docs/tags.md | 10 +- docs/usage.md | 20 +- flake.nix | 4 + mkdocs.yml | 25 +- nix/package/default.nix | 8 + nix/package/pillow-jxl-plugin.nix | 4 + nix/package/pyexiv2.nix | 4 + nix/shell.nix | 4 + overrides/partials/header.html | 23 +- overrides/partials/nav.html | 23 +- overrides/partials/toc-item.html | 42 +- pyproject.toml | 51 +- src/tagstudio/core/constants.py | 9 +- src/tagstudio/core/driver.py | 4 + src/tagstudio/core/enums.py | 35 +- src/tagstudio/core/exceptions.py | 5 +- .../core/library/alchemy/constants.py | 8 +- src/tagstudio/core/library/alchemy/db.py | 10 +- .../library/alchemy/default_color_groups.py | 5 +- src/tagstudio/core/library/alchemy/enums.py | 17 +- src/tagstudio/core/library/alchemy/fields.py | 206 +-- src/tagstudio/core/library/alchemy/joins.py | 5 +- src/tagstudio/core/library/alchemy/library.py | 973 ++++++------ src/tagstudio/core/library/alchemy/models.py | 73 +- .../alchemy/registries/dupe_files_registry.py | 22 +- .../alchemy/registries/ignored_registry.py | 5 +- .../alchemy/registries/unlinked_registry.py | 4 + .../core/library/alchemy/visitors.py | 21 +- src/tagstudio/core/library/ignore.py | 23 +- src/tagstudio/core/library/json/fields.py | 4 + src/tagstudio/core/library/json/library.py | 6 +- src/tagstudio/core/library/refresh.py | 11 +- src/tagstudio/core/media_types.py | 34 +- src/tagstudio/core/query_lang/ast.py | 9 +- src/tagstudio/core/query_lang/parser.py | 5 +- src/tagstudio/core/query_lang/tokenizer.py | 7 +- src/tagstudio/core/query_lang/util.py | 5 +- src/tagstudio/core/ts_core.py | 204 +-- src/tagstudio/core/utils/encoding.py | 6 +- src/tagstudio/core/utils/silent_subprocess.py | 6 +- src/tagstudio/core/utils/singleton.py | 7 +- src/tagstudio/core/utils/str_formatting.py | 23 +- src/tagstudio/core/utils/types.py | 5 +- src/tagstudio/main.py | 5 +- src/tagstudio/qt/cache_manager.py | 6 +- .../controllers/ffmpeg_missing_message_box.py | 4 + .../fix_ignored_modal_controller.py | 25 +- .../qt/controllers/ignore_modal_controller.py | 7 +- .../library_info_window_controller.py | 11 +- .../qt/controllers/out_of_date_message_box.py | 47 + .../qt/controllers/paged_panel_controller.py | 5 +- .../qt/controllers/paged_panel_state.py | 5 +- .../controllers/preview_panel_controller.py | 8 +- .../controllers/preview_thumb_controller.py | 19 +- .../qt/controllers/tag_box_controller.py | 12 +- src/tagstudio/qt/global_settings.py | 6 +- src/tagstudio/qt/helpers/color_overlay.py | 5 +- src/tagstudio/qt/helpers/escape_text.py | 5 +- src/tagstudio/qt/helpers/file_tester.py | 5 +- src/tagstudio/qt/helpers/gradients.py | 5 +- src/tagstudio/qt/helpers/image_effects.py | 5 +- src/tagstudio/qt/helpers/text_wrapper.py | 9 +- src/tagstudio/qt/mixed/about_modal.py | 20 +- src/tagstudio/qt/mixed/add_field.py | 18 +- src/tagstudio/qt/mixed/build_color.py | 5 +- src/tagstudio/qt/mixed/build_namespace.py | 5 +- src/tagstudio/qt/mixed/build_tag.py | 11 +- src/tagstudio/qt/mixed/collage_icon.py | 5 +- src/tagstudio/qt/mixed/color_box.py | 7 +- src/tagstudio/qt/mixed/datetime_picker.py | 4 + src/tagstudio/qt/mixed/drop_import_modal.py | 5 +- src/tagstudio/qt/mixed/field_containers.py | 141 +- src/tagstudio/qt/mixed/field_widget.py | 5 +- src/tagstudio/qt/mixed/file_attributes.py | 6 +- src/tagstudio/qt/mixed/fix_dupe_files.py | 5 +- src/tagstudio/qt/mixed/fix_unlinked.py | 5 +- src/tagstudio/qt/mixed/folders_to_tags.py | 5 +- src/tagstudio/qt/mixed/item_thumb.py | 23 +- src/tagstudio/qt/mixed/landing.py | 5 +- src/tagstudio/qt/mixed/media_player.py | 8 +- src/tagstudio/qt/mixed/merge_dupe_entries.py | 5 +- src/tagstudio/qt/mixed/migration_modal.py | 139 +- .../qt/mixed/mirror_entries_modal.py | 7 +- src/tagstudio/qt/mixed/pagination.py | 11 +- src/tagstudio/qt/mixed/progress_bar.py | 7 +- .../qt/mixed/relink_entries_modal.py | 5 +- .../qt/mixed/remove_ignored_modal.py | 5 +- .../qt/mixed/remove_unlinked_modal.py | 5 +- src/tagstudio/qt/mixed/settings_panel.py | 14 +- src/tagstudio/qt/mixed/tag_color_label.py | 5 +- src/tagstudio/qt/mixed/tag_color_manager.py | 6 +- src/tagstudio/qt/mixed/tag_color_preview.py | 5 +- src/tagstudio/qt/mixed/tag_color_selection.py | 5 +- src/tagstudio/qt/mixed/tag_database.py | 11 +- src/tagstudio/qt/mixed/tag_search.py | 54 +- src/tagstudio/qt/mixed/tag_widget.py | 5 +- src/tagstudio/qt/mixed/text_field.py | 9 +- src/tagstudio/qt/mnemonics.py | 37 +- src/tagstudio/qt/models/palette.py | 6 +- src/tagstudio/qt/platform_strings.py | 5 +- src/tagstudio/qt/previews/renderer.py | 315 +++- .../qt/previews/vendored/blender_renderer.py | 25 +- src/tagstudio/qt/previews/vendored/ffmpeg.py | 6 +- .../previews/vendored/pydub/audio_segment.py | 6 +- .../qt/previews/vendored/pydub/utils.py | 6 + src/tagstudio/qt/resource_manager.py | 5 +- src/tagstudio/qt/resources.qrc | 4 +- src/tagstudio/qt/resources_rc.py | 4 + src/tagstudio/qt/thumb_grid_layout.py | 122 +- src/tagstudio/qt/translations.py | 12 + src/tagstudio/qt/ts_qt.py | 276 ++-- src/tagstudio/qt/utils/custom_runnable.py | 7 +- src/tagstudio/qt/utils/file_deleter.py | 5 +- src/tagstudio/qt/utils/file_opener.py | 6 +- src/tagstudio/qt/utils/function_iterator.py | 5 +- src/tagstudio/qt/views/clickable_label.py | 5 +- src/tagstudio/qt/views/clickable_slider.py | 6 +- src/tagstudio/qt/views/edit_text_box_modal.py | 5 +- .../qt/views/edit_text_line_modal.py | 5 +- .../qt/views/fix_ignored_modal_view.py | 5 +- src/tagstudio/qt/views/ignore_modal_view.py | 5 +- src/tagstudio/qt/views/layouts/flow_layout.py | 7 +- .../qt/views/library_info_window_view.py | 5 +- src/tagstudio/qt/views/main_window.py | 5 +- src/tagstudio/qt/views/paged_body_wrapper.py | 5 +- src/tagstudio/qt/views/panel_modal.py | 5 +- src/tagstudio/qt/views/preview_panel_view.py | 5 +- src/tagstudio/qt/views/preview_thumb_view.py | 5 +- src/tagstudio/qt/views/qbutton_wrapper.py | 5 +- src/tagstudio/qt/views/splash.py | 5 +- .../qt/views/styles/rounded_pixmap_style.py | 8 +- src/tagstudio/qt/views/tag_box_view.py | 4 +- src/tagstudio/qt/views/thumb_button.py | 11 +- .../qt/fonts/Oxanium-Bold.ttf.license | 2 + src/tagstudio/resources/translations/am.json | 46 + src/tagstudio/resources/translations/ceb.json | 111 ++ src/tagstudio/resources/translations/de.json | 34 +- src/tagstudio/resources/translations/el.json | 162 ++ src/tagstudio/resources/translations/en.json | 10 +- src/tagstudio/resources/translations/es.json | 22 +- src/tagstudio/resources/translations/fi.json | 321 ++++ src/tagstudio/resources/translations/fil.json | 2 - src/tagstudio/resources/translations/fr.json | 50 +- src/tagstudio/resources/translations/hu.json | 16 +- src/tagstudio/resources/translations/is.json | 42 + src/tagstudio/resources/translations/it.json | 22 +- src/tagstudio/resources/translations/ja.json | 7 +- .../resources/translations/nb_NO.json | 6 +- src/tagstudio/resources/translations/nl.json | 11 + src/tagstudio/resources/translations/pl.json | 6 +- src/tagstudio/resources/translations/pt.json | 4 +- .../resources/translations/pt_BR.json | 99 +- src/tagstudio/resources/translations/qpv.json | 2 - src/tagstudio/resources/translations/ru.json | 17 +- src/tagstudio/resources/translations/sv.json | 45 +- src/tagstudio/resources/translations/ta.json | 86 +- src/tagstudio/resources/translations/th.json | 8 + src/tagstudio/resources/translations/tok.json | 87 +- src/tagstudio/resources/translations/tr.json | 4 +- .../resources/translations/zh_Hans.json | 29 +- .../resources/translations/zh_Hant.json | 69 +- tagstudio.spec | 4 + tests/conftest.py | 21 +- .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 102400 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 106496 bytes .../.TagStudio/ts_library.sqlite | Bin 114688 -> 114688 bytes tests/fixtures/sidecar_newgrounds.json | 5 +- tests/macros/test_dupe_files.py | 13 +- tests/macros/test_folders_tags.py | 6 +- tests/macros/test_missing_files.py | 6 +- tests/macros/test_refresh_dir.py | 33 +- tests/qt/test_about_modal.py | 18 + tests/qt/test_build_tag_panel.py | 5 +- tests/qt/test_field_containers.py | 5 +- tests/qt/test_file_path_options.py | 5 +- tests/qt/test_flow_widget.py | 5 +- tests/qt/test_folders_to_tags.py | 5 +- tests/qt/test_global_settings.py | 5 +- tests/qt/test_item_thumb.py | 5 +- tests/qt/test_preview_panel.py | 23 +- tests/qt/test_qt_driver.py | 6 +- tests/qt/test_resource_manager.py | 5 +- tests/qt/test_tag_panel.py | 5 +- tests/qt/test_tag_search_panel.py | 9 +- tests/qt/test_theme_system.py | 35 + tests/test_db_migrations.py | 14 +- tests/test_driver.py | 5 +- tests/test_json_migration.py | 14 +- tests/test_library.py | 208 ++- tests/test_search.py | 5 +- tests/test_translations.py | 5 +- 237 files changed, 6571 insertions(+), 3412 deletions(-) create mode 100644 .editorconfig rename .github/workflows/{mypy.yaml => pyright.yaml} (50%) create mode 100644 .github/workflows/reuse.yaml create mode 100644 .prettierignore create mode 100644 .prettierrc.toml create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/CC-BY-SA-4.0.txt create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 LICENSES/GPL-3.0-only.txt create mode 120000 LICENSES/GPL-3.0-or-later.txt create mode 100644 LICENSES/MIT.txt create mode 100644 LICENSES/OFL-1.1.txt create mode 100644 REUSE.toml create mode 100644 src/tagstudio/qt/controllers/out_of_date_message_box.py create mode 100644 src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license create mode 100644 src/tagstudio/resources/translations/am.json create mode 100644 src/tagstudio/resources/translations/ceb.json create mode 100644 src/tagstudio/resources/translations/el.json create mode 100644 src/tagstudio/resources/translations/fi.json create mode 100644 src/tagstudio/resources/translations/is.json create mode 100644 src/tagstudio/resources/translations/th.json create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_200/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_201/.TagStudio/ts_library.sqlite create mode 100644 tests/qt/test_about_modal.py create mode 100644 tests/qt/test_theme_system.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..cca280d60 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +# EditorConfig https://editorconfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +max_line_length = 100 +trim_trailing_whitespace = true + +[*.{css,json,md}] +indent_size = 4 + +[*.{yaml,yml}] +indent_size = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 888afa678..6b7cb17f5 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -5,3 +5,7 @@ # Date: Fri, 13 Sep 2024 00:28:00 -0700 # ci(ruff)!: update ruff linter config, refactor to comply b6e216760557c5507b12f210e1e48c531f49ffa3 + +# Date: Tue, 12 May 2026 09:24:04 -0400 +# refactor(docs): uniform formatting pass (#1363) +e134cb1ecb888138411e804d15db8b1a7ede0cb0 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c2cdaaefd..9c116c5e2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,5 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + --- patreon: cyanvoxel diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a719991ff..6dfd85c20 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,8 +1,10 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- name: Bug Report description: File a bug or issue report. -title: '[Bug]: ' -labels: ['Type: Bug'] +title: "[Bug]: " +labels: ["Type: Bug"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 32259fb6a..e40eb9eb4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,8 +1,10 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- name: Feature Request description: Suggest a new feature. -title: '[Feature Request]: ' -labels: ['Type: Enhancement'] +title: "[Feature Request]: " +labels: ["Type: Enhancement"] body: - type: markdown attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9b1d96063..48ade431a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,6 @@ + + + ### Summary -- Platforms Tested: - - [ ] Windows x86 - - [ ] Windows ARM - - [ ] macOS x86 - - [ ] macOS ARM - - [ ] Linux x86 - - [ ] Linux ARM - -- Tested For: - - [ ] Basic functionality - - [ ] PyInstaller executable - +- Platforms Tested: + - [ ] Windows x86 + - [ ] Windows ARM + - [ ] macOS x86 + - [ ] macOS ARM + - [ ] Linux x86 + - [ ] Linux ARM + +- Tested For: + - [ ] Basic functionality + - [ ] PyInstaller executable + diff --git a/.github/workflows/publish_docs.yaml b/.github/workflows/publish_docs.yaml index 4bda2b75b..ff59938a2 100644 --- a/.github/workflows/publish_docs.yaml +++ b/.github/workflows/publish_docs.yaml @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- name: Publish Docs @@ -28,7 +30,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" cache: pip - name: Install Python dependencies @@ -43,7 +45,7 @@ jobs: key: mkdocs-material-${{ env.cache_id }} path: .cache restore-keys: | - mkdocs-material- + mkdocs-material- - name: Execute mkdocs run: mkdocs gh-deploy --force diff --git a/.github/workflows/mypy.yaml b/.github/workflows/pyright.yaml similarity index 50% rename from .github/workflows/mypy.yaml rename to .github/workflows/pyright.yaml index df6956922..59de9bf3a 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/pyright.yaml @@ -1,34 +1,31 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- -name: MyPy +name: Pyright on: [push, pull_request] jobs: - mypy: - name: Run MyPy + pyright: + name: Run Pyright runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v4 - - name: Setup reviewdog - uses: reviewdog/action-setup@v1 - with: - reviewdog_version: latest - - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" cache: pip - name: Install Python dependencies run: | python -m pip install --upgrade uv - uv pip install --system .[mypy] + uv pip install --system .[pyright,pytest] - - name: Execute MyPy - uses: tsuyoshicho/action-mypy@v4 + - name: Execute Pyright + uses: jordemort/action-pyright@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - fail_on_error: true + reporter: ${{ github.event_name == 'pull_request' && 'github-pr-review' || 'github-check' }} diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 78b730074..2c2b01416 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- name: pytest @@ -17,7 +19,7 @@ jobs: name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" cache: pip - &install-dependencies @@ -43,6 +45,7 @@ jobs: libxcb-xinerama0 \ libxkbcommon-x11-0 \ libyaml-dev \ + ripgrep \ x11-utils - name: Execute pytest @@ -86,9 +89,9 @@ jobs: - name: Check coverage uses: yedpodtrzitko/coverage@main with: - thresholdAll: 0.4 - thresholdNew: 0.4 - thresholdModified: 0.4 + thresholdAll: 0.1 + thresholdNew: 0.1 + thresholdModified: 0.1 coverageFile: coverage.xml token: ${{ secrets.GITHUB_TOKEN }} sourceDir: tagstudio/src diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59354c17f..f305f91e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- name: Release @@ -10,11 +12,11 @@ jobs: linux: strategy: matrix: - build-type: ['', portable] + build-type: ["", portable] include: - - build-type: '' - build-flag: '' - suffix: '' + - build-type: "" + build-flag: "" + suffix: "" - build-type: portable build-flag: --portable suffix: _portable @@ -26,7 +28,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" cache: pip - name: Install Python dependencies @@ -48,18 +50,18 @@ jobs: macos: strategy: matrix: - os-version: ['13', '14'] + os-version: ["14", "15"] include: - - os-version: '13' + - os-version: "14" arch: x86_64 - - os-version: '14' + - os-version: "15" arch: aarch64 runs-on: macos-${{ matrix.os-version }} env: - # INFO: Even though we run on 13, target towards compatibility - MACOSX_DEPLOYMENT_TARGET: '11.0' + # INFO: Even though we run on 14, target towards compatibility + MACOSX_DEPLOYMENT_TARGET: "11.0" steps: - name: Checkout repo @@ -68,7 +70,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" cache: pip - name: Install Python dependencies @@ -76,7 +78,6 @@ jobs: python -m pip install --upgrade uv uv pip install --system .[pyinstaller] - - name: Execute PyInstaller run: | pyinstaller tagstudio.spec @@ -90,12 +91,12 @@ jobs: windows: strategy: matrix: - build-type: ['', portable] + build-type: ["", portable] include: - - build-type: '' - build-flag: '' - suffix: '' - file-end: '' + - build-type: "" + build-flag: "" + suffix: "" + file-end: "" - build-type: portable build-flag: --portable suffix: _portable @@ -110,7 +111,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" cache: pip - name: Install Python dependencies diff --git a/.github/workflows/reuse.yaml b/.github/workflows/reuse.yaml new file mode 100644 index 000000000..4a39733f5 --- /dev/null +++ b/.github/workflows/reuse.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2020 Free Software Foundation Europe e.V. +# SPDX-License-Identifier: CC0-1.0 +name: REUSE compliance check +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: REUSE Compliance Check + uses: fsfe/reuse-action@v6 diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index 2fc85b7c6..92550f082 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only --- name: Ruff diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 525247423..654dd28ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,9 +2,9 @@ repos: - repo: local hooks: - - id: mypy - name: mypy - entry: mypy + - id: pyright + name: pyright + entry: pyright language: system types_or: [python, pyi] require_serial: true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..b300b3f77 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +overrides/partials/*.html +tests/fixtures/json_library/.TagStudio/*.json diff --git a/.prettierrc.toml b/.prettierrc.toml new file mode 100644 index 000000000..8b4b3be75 --- /dev/null +++ b/.prettierrc.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +# Prettier https://prettier.io/ + +bracketSameLine = false +bracketSpacing = true +objectWrap = "preserve" +quoteProps = "as-needed" +singleQuote = false diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt new file mode 100644 index 000000000..2d58298e6 --- /dev/null +++ b/LICENSES/CC-BY-SA-4.0.txt @@ -0,0 +1,428 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 000000000..0e259d42c --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/GPL-3.0-only.txt b/LICENSES/GPL-3.0-only.txt new file mode 100644 index 000000000..d04d2e74e --- /dev/null +++ b/LICENSES/GPL-3.0-only.txt @@ -0,0 +1,675 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/LICENSES/GPL-3.0-or-later.txt b/LICENSES/GPL-3.0-or-later.txt new file mode 120000 index 000000000..9928c0062 --- /dev/null +++ b/LICENSES/GPL-3.0-or-later.txt @@ -0,0 +1 @@ +GPL-3.0-only.txt \ No newline at end of file diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 000000000..fe36007e1 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,19 @@ +Copyright © + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/LICENSES/OFL-1.1.txt b/LICENSES/OFL-1.1.txt new file mode 100644 index 000000000..40bd8a65d --- /dev/null +++ b/LICENSES/OFL-1.1.txt @@ -0,0 +1,97 @@ +Copyright (c) , (), +with Reserved Font Name . +Copyright (c) , (), +with Reserved Font Name . +Copyright (c) , (). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/README.md b/README.md index 852d91fe7..2d685ee91 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ + + + # TagStudio: A User-Focused Photo & File Management System [![Downloads](https://img.shields.io/github/downloads/TagStudioDev/TagStudio/total.svg?maxAge=2592001)](https://github.com/TagStudioDev/TagStudio/releases) @@ -21,11 +24,11 @@ TagStudio is a photo & file organization application with an underlying tag-base ## Contents -- [Feature Highlights](#feature-highlights) -- [Basic Usage](#basic-usage) -- [Installation](#installation) -- [Goals & Priorities](#goals--priorities) -- [FAQ](#faq) +- [Feature Highlights](#feature-highlights) +- [Basic Usage](#basic-usage) +- [Installation](#installation) +- [Goals & Priorities](#goals--priorities) +- [FAQ](#faq) Translation hosting generously provided by [Weblate](https://weblate.org/en/). Check out our [project page](https://hosted.weblate.org/projects/tagstudio/) to help translate TagStudio! @@ -43,16 +46,16 @@ All file types are supported in TagStudio libraries, just not all have dedicated For a generalized list of what's currently supported: -- **Images** - - Raster Images (JPEG, PNG, etc.) - - Vector (SVG) - - Animated (GIF, WEBP, APNG) - - RAW Formats -- **Videos** -- **Plaintext Files** -- **Documents** _(If supported)_ -- **eBooks** _(If supported)_ -- **Photoshop PSDs**, **Blender Projects**, **Krita Projects**, and more! +- **Images** + - Raster Images (JPEG, PNG, etc.) + - Vector (SVG) + - Animated (GIF, WEBP, APNG) + - RAW Formats +- **Videos** +- **Plaintext Files** +- **Documents** _(If supported)_ +- **eBooks** _(If supported)_ +- **Photoshop PSDs**, **Blender Projects**, **Krita Projects**, and more! ### [Tags](https://docs.tagstud.io/tags) and [Fields](https://docs.tagstud.io/fields) @@ -60,28 +63,28 @@ Tags represent an object or attribute - this could be a person, place, object, c Tags currently consist of the following attributes: -- **Name**: The full name for your tag. **_This does NOT have to be unique!_** -- **Shorthand Name**: The shortest alternate name for your tag, used for abbreviations. -- **Aliases**: Alternate names your tag goes by. -- **Color**: The display color of your tag. -- **Parent Tags**: Other tags in which this tag inherits from. In practice, this means that this tag can be substituted in searches for any listed parent tags. - - Parent tags checked with the "disambiguation" checkbox next to them will be used to help disambiguate tag names that may not be unique. - - For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)". -- **Is Category**: A property that when checked, treats this tag as a category in the preview panel. +- **Name**: The full name for your tag. **_This does NOT have to be unique!_** +- **Shorthand Name**: The shortest alternate name for your tag, used for abbreviations. +- **Aliases**: Alternate names your tag goes by. +- **Color**: The display color of your tag. +- **Parent Tags**: Other tags in which this tag inherits from. In practice, this means that this tag can be substituted in searches for any listed parent tags. + - Parent tags checked with the "disambiguation" checkbox next to them will be used to help disambiguate tag names that may not be unique. + - For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)". +- **Is Category**: A property that when checked, treats this tag as a category in the preview panel. Fields, like tags, are additional pieces of custom metadata that you can add to your file entries. Fields currently have several hardcoded names (e.g. "Title", "Author", "Series") but custom field names are planned for an upcoming update. Field types currently include: -- **Text Lines**: Single lines of text. -- **Text Boxes**: Multi-line pieces of text. -- **Datetimes**: Dates and times. +- **Text Lines**: Single lines of text. +- **Text Boxes**: Multi-line pieces of text. +- **Datetimes**: Dates and times. ### [Search](https://docs.tagstud.io/search) -- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`). Path searches currently use [glob]() syntax, so you may need to wrap your filename or filepath in asterisks while searching. This will not be strictly necessary in future versions of the program. -- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries -- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively +- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`). Path searches currently use [glob]() syntax, so you may need to wrap your filename or filepath in asterisks while searching. This will not be strictly necessary in future versions of the program. +- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries +- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively ## Basic Usage @@ -165,11 +168,11 @@ See the [**Roadmap**](docs/roadmap.md) on the documentation site for a complete ### Overall Goals -- To achieve a portable, private, extensible, open-format, and feature-rich system of organizing and rediscovering files. -- To provide powerful methods for organization, notably the concept of tag inheritance, or "taggable tags" _(and in the near future, the combination of composition-based tags)._ -- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows. -- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries. -- To make the dang thing look nice, too. It’s 2025, not 1995. +- To achieve a portable, private, extensible, open-format, and feature-rich system of organizing and rediscovering files. +- To provide powerful methods for organization, notably the concept of tag inheritance, or "taggable tags" _(and in the near future, the combination of composition-based tags)._ +- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows. +- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries. +- To make the dang thing look nice, too. It’s 2025, not 1995. ### Project Priorities @@ -197,29 +200,29 @@ See the [roadmap](https://docs.tagstud.io/roadmap) page for the core features be The most important remaining features before I consider the program to be "feature complete" are: -- Custom names for Fields -- List views for files -- Multiple root directory support for libraries -- Improved file entry relinking -- File entry groups -- Sorting by file date modified and created -- Macros -- Improved search bar with visualized tags and improved autocomplete -- Side panel for easier tagging (pinned tags, recent tags, tag search, tag palette) -- Improved tag management interface -- Improved and finalized Tag Categories -- Fixed and improved mixed entry data displays (see: [#337](https://github.com/TagStudioDev/TagStudio/issues/337)) -- Sharable tag data -- Separate core library + API +- Custom names for Fields +- List views for files +- Multiple root directory support for libraries +- Improved file entry relinking +- File entry groups +- Sorting by file date modified and created +- Macros +- Improved search bar with visualized tags and improved autocomplete +- Side panel for easier tagging (pinned tags, recent tags, tag search, tag palette) +- Improved tag management interface +- Improved and finalized Tag Categories +- Fixed and improved mixed entry data displays (see: [#337](https://github.com/TagStudioDev/TagStudio/issues/337)) +- Sharable tag data +- Separate core library + API ### What features will NOT be added? -- Native Cloud Integration - - There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Hosting a TagStudio library on one of these mounts should function similarly to what native integration would look like. - - Supporting native cloud integrations such as these would be an unnecessary "reinventing the wheel" burden for us that is outside the scope of this project. -- Native ChatGPT/Claude/Gemini/_Non-Local_ LLM Integration - - This could mean different things depending on your intentions. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Overall Goals/Privacy](#overall-goals)). - - With that being said, the future TagStudio API should be well-suited to connect to any sort of service you'd like, including machine learning models if so you choose. I just won't _personally_ add any native integrations with online services. +- Native Cloud Integration + - There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Hosting a TagStudio library on one of these mounts should function similarly to what native integration would look like. + - Supporting native cloud integrations such as these would be an unnecessary "reinventing the wheel" burden for us that is outside the scope of this project. +- Native ChatGPT/Claude/Gemini/_Non-Local_ LLM Integration + - This could mean different things depending on your intentions. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Overall Goals/Privacy](#overall-goals)). + - With that being said, the future TagStudio API should be well-suited to connect to any sort of service you'd like, including machine learning models if so you choose. I just won't _personally_ add any native integrations with online services. ### Is a Rust port coming? diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 000000000..1897ada11 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,49 @@ +version = 1 + +[[annotations]] +path = [ + "tests/fixtures/**", + + "contrib/.envrc-nix", + "contrib/.envrc-uv", + "contrib/.vscode/launch.json", + "docs/CNAME", + "docs/assets/**", + "src/tagstudio/qt/resources.json", + "src/tagstudio/resources/icon.*", + "src/tagstudio/resources/tagstudio.desktop", + "src/tagstudio/resources/templates/ts_ignore_template.txt", + "src/tagstudio/resources/templates/ts_ignore_template_blank.txt", + "src/tagstudio/resources/qt/images/**", + "tests/qt/__snapshots__/test_folders_to_tags.ambr", + + ".git-blame-ignore-revs", + ".gitattributes", + ".gitignore", + ".pre-commit-config.yaml", + "flake.lock", +] +SPDX-FileCopyrightText = "(c) TagStudio Contributors" +SPDX-License-Identifier = "GPL-3.0-only" + +[[annotations]] +path = ["src/tagstudio/resources/translations/*.json"] +SPDX-FileCopyrightText = "(c) TagStudio Contributors" +SPDX-License-Identifier = "GPL-3.0-or-later" + +[[annotations]] +path = [ + "src/tagstudio/resources/qt/images/bxs-left-arrow.png", + "src/tagstudio/resources/qt/images/bxs-right-arrow.png", + "src/tagstudio/resources/qt/images/file_icons/database.png", +] +SPDX-FileCopyrightText = "(c) 2026 Boxicons" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "src/tagstudio/resources/qt/images/volume.svg", + "src/tagstudio/resources/qt/images/volume_mute.svg", +] +SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors" +SPDX-License-Identifier = "Apache-2.0" diff --git a/contrib/.vscode/launch.json b/contrib/.vscode/launch.json index 9ad1d3e62..28e5523db 100644 --- a/contrib/.vscode/launch.json +++ b/contrib/.vscode/launch.json @@ -8,10 +8,7 @@ "program": "${workspaceRoot}/src/tagstudio/main.py", "console": "integratedTerminal", "justMyCode": true, - "args": [ - "-o", - "~/Documents/Example" - ] + "args": ["-o", "~/Documents/Example"] } ] } diff --git a/docs/changelog.md b/docs/changelog.md index a3c6474ae..ec647a579 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,75 +4,182 @@ icon: material/script-text toc_depth: 2 --- + + + # :material-script-text: Changelog -## 9.5.5 [September 8th, 2025] +## 9.5.7 May 5th, 2026 + +This update adds several bugfixes and additions that have been sitting on the main branch for quite some time. + +### Added + +- feat: add hidden tags by @TrigamDev in #1139 +- feat: render `.pdn` thumbnails. by @Sola-ris in #1149 +- feat: render `.mdp` thumbnails. by @Sola-ris in #1153 +- feat: update notification by @Computerdores in #1166 +- feat: render `.clip` thumbnails. by @Sola-ris in #1150 +- feat: render archive thumbnails by @Sola-ris in #1194 + +### Fixed + +- fix: "Search for Tag" in Tag Manager executes multiple queries by @CallMeHein in #1173 +- fix: 'Add Tag to Selected' action fails by @TrigamDev in #1224 +- fix: escape dash in URL regex by @TrigamDev in #1255 +- fix: running the 'Fix Ignored Entries' tool from the menu bar causes an error in the log by @TrigamDev in #1188 +- fix: when deleting tag remove all TagParent rows with it's id by @TheBobBobs in #1250 +- fix: remove entry even if deleting it's file failed by @TheBobBobs in #1246 +- fix: tab order in build_tag modal by @Computerdores in #1235 +- fix: prevent deadlock when wanted mnemonics conflict by @Computerdores in #1200 +- fix: call ripgrep with explicit utf-8 encoding. by @Sola-ris in #1199 +- fix: persist entry selection across pages and save scroll positions by @TheBobBobs in #1248 +- fix: MacOS system theme fix (#999) by @terahidro2003 in #1328 +- perf: Bulk insert/delete tag_entries by @TheBobBobs in #1296 + +### Changed + +#### Internal Changes + +- fix(nix): replace wrapGAppsHook with wrapGAppsHook3 by @Ambossmann in #1189 +- feat: add windows runner for pytest by @Sola-ris in #1201 +- chore(thumb_renderer): bump Pillow by @xarvex in #1227 +- fix(nix): add requests and semver to nix package by @Ambossmann in #1265 +- fix: errors in DupeFilesRegistry by @Computerdores in #1233 +- fix: pyright errors in blender_renderer.py by @Computerdores in #1236 + +#### Translations + +- **Cebuano** added by @StartsMercury +- **Chinese (Simpliflied Han Script)** updated by @ngivanyh +- **Chinese (Traditional Han Script)** updated by @ngivanyh +- **Dutch** updated by @timomen +- **French** updated by @kitsumed +- **Finnish** updated by @JonneSaloranta +- **German** updated by @Dariton4000, @HerrChaos +- **Greek** updated by @Gvolexe +- **Hungarian** updated by @smileyhead +- **Icelandic** updated by @kristinnssig +- **Italian** updated by @EdelFlosWeiss +- **Japanese** updated by wany-oh +- **Portugese (Brazil)** updated by José Victor, dmto dmto, @AsmodeumX +- **Spanish** updated by @JCC1998, @JulArr22, @r40s-0 +- **Swedish** updated by @vimml +- **Tamil** updated by @TamilNeram +- **Toki Pona** updated by @Math-Bee, Star Athendwyl + +### New Contributors + +- @Ambossmann made their first contribution in #1189 +- @CallMeHein made their first contribution in #1173 +- @terahidro2003 made their first contribution in #1328 + +--- + +## 9.5.6 October 20th, 2025 + +### Added + +- feat: render .cb7 thumbnails. by @Sola-ris in #1118 +- feat: add infinite scrolling, improve page performance by @TheBobBobs in #1119 + +### Fixed + +- fix: process ignore patterns for wcmatch in unlinked registry by @CyanVoxel in #1124 +- fix: respect trailing slash patterns in glob by @CyanVoxel in #1127 +- fix: always hide duration badge on non video ext by @TheBobBobs in #1134 +- fix: update entry cache when toggling tags by @TheBobBobs in #1135 +- fix: use absolute path for file opener by @TheBobBobs in #1136 +- fix: toggle play only with left mouse button click by @csponge in #1152 +- fix: Fix searching `A AND A` returning no results by @TrigamDev in #1138 +- fix: add periodic yield to save_new_files by @TheBobBobs in #1040 + +### Changed + +#### Internal Changes + +- fix: apply unwrap where necessary by @Computerdores in #1113 +- fix: renderer type fixes by @Computerdores in #1114 + +#### Translations + +- **Dutch** updated by @FlannyH +- **French** updated by @kitsumed +- **Hungarian** updated by @smileyhead +- **Italian** added and updated by @OmnipresentW +- **Japanese** updated by wany-oh +- **Norwegian Bokmål** updated by @Neemek +- **Spanish** updated by @JCC1999 + +--- + +## 9.5.5 September 8th, 2025 ### Added #### New Settings -- feat(ui): add thumbnail cache size setting to settings panel by @CyanVoxel in #1088 -- feat: add cached thumbnail quality and resolution settings by @CyanVoxel in #1101 - - Only available by editing the `cached_thumb_quality` and `cached_thumb_resolution` options in the `settings.toml` config file -- fix: add option to use old Windows 'start' command by @CyanVoxel in #1084 - - Only available by editing the `windows_start_command` option in the `settings.toml` file - - Fixes niche issue on Windows systems, see #1036 -- translations: add Czech, Portuguese (Portugal), and Romanian to settings panel (2db8bed) +- feat(ui): add thumbnail cache size setting to settings panel by @CyanVoxel in #1088 +- feat: add cached thumbnail quality and resolution settings by @CyanVoxel in #1101 + - Only available by editing the `cached_thumb_quality` and `cached_thumb_resolution` options in the `settings.toml` config file +- fix: add option to use old Windows 'start' command by @CyanVoxel in #1084 + - Only available by editing the `windows_start_command` option in the `settings.toml` file + - Fixes niche issue on Windows systems, see #1036 +- translations: add Czech, Portuguese (Portugal), and Romanian to settings panel (2db8bed) #### File Previews -- feat: render .cbr thumbnails by @Sola-ris in #1112 -- feat: render .cbt thumbnails by @Sola-ris in #1116 +- feat: render .cbr thumbnails by @Sola-ris in #1112 +- feat: render .cbt thumbnails by @Sola-ris in #1116 ### Fixed -- fix: JSON migration window getting stuck on finishing migration by @CyanVoxel in #1094 -- fix: VTF files not rendering on Linux by @CyanVoxel in #1093 -- fix: account for leading slash ignore pattern by @CyanVoxel in #1092 -- fix: add option to use old Windows 'start' command by @CyanVoxel in #1084 -- fix: always show first frame of video; autoplay will always play by @SumithSudheer and @CyanVoxel in #1104 -- feat: read epub cover from ComicInfo.xml, if available. by @Sola-ris in #1109 and #1111 -- fix: prevent mnemonic removal from removing escaped ampersands by @CyanVoxel in #1110 -- fix: properly delete tag_parents row when deleting tag by @CyanVoxel in #1107 +- fix: JSON migration window getting stuck on finishing migration by @CyanVoxel in #1094 +- fix: VTF files not rendering on Linux by @CyanVoxel in #1093 +- fix: account for leading slash ignore pattern by @CyanVoxel in #1092 +- fix: add option to use old Windows 'start' command by @CyanVoxel in #1084 +- fix: always show first frame of video; autoplay will always play by @SumithSudheer and @CyanVoxel in #1104 +- feat: read epub cover from ComicInfo.xml, if available. by @Sola-ris in #1109 and #1111 +- fix: prevent mnemonic removal from removing escaped ampersands by @CyanVoxel in #1110 +- fix: properly delete tag_parents row when deleting tag by @CyanVoxel in #1107 ### Changed #### Translations -- **French** updated by @kitsumed , @RustyNova016 -- **Hungarian** updated by @smileyhead -- **Russian** updated by @purpletennisball -- **Spanish** updated by @danpg94 -- **Toki Pona** updated by @Math-Bee +- **French** updated by @kitsumed , @RustyNova016 +- **Hungarian** updated by @smileyhead +- **Russian** updated by @purpletennisball +- **Spanish** updated by @danpg94 +- **Toki Pona** updated by @Math-Bee #### Internal Changes -- refactor: untangle backend and frontend files by @CyanVoxel in #1095 -- refactor: fix most pyright issues in `library/alchemy/` by @CyanVoxel in #1103 +- refactor: untangle backend and frontend files by @CyanVoxel in #1095 +- refactor: fix most pyright issues in `library/alchemy/` by @CyanVoxel in #1103 --- -## 9.5.4 [September 1st, 2025] +## 9.5.4 September 1st, 2025 ### Added #### `.ts_ignore` File and Folder Ignore System -The previous system for ignoring file extensions has been replaced by a new `.gitignore`-style pattern matching system. This uses a `.ts_ignore` file inside your library's `.TagStudio` folder with glob-like rules to give more power options than what was previously possible. This file can be edited inside within TagStudio or externally, and rules are hot-reloaded in either case. Existing extension rules have been migrated as closely as possible to this new system. For more information on this new system, visit the "[Ignore Files](https://docs.tagstud.io/utilities/ignore/)" page on the documentation site. +The previous system for ignoring file extensions has been replaced by a new `.gitignore`-style pattern matching system. This uses a `.ts_ignore` file inside your library's `.TagStudio` folder with glob-like rules to give more power options than what was previously possible. This file can be edited inside within TagStudio or externally, and rules are hot-reloaded in either case. Existing extension rules have been migrated as closely as possible to this new system. For more information on this new system, visit the "[Ignore Files](https://docs.tagstud.io/ignore/)" page on the documentation site. Screenshot 2025-08-22 at 14 31 15 Along with this system also comes the additional features: -- TagStudio can now traverse symlinks in your library folders -- TagStudio can now leverage [ripgrep](https://github.com/BurntSushi/ripgrep), a rust-based directory search tool, for faster library refreshing - - ripgrep must be [installed on your system](https://docs.tagstud.io/install/#ripgrep) and able to be located by TagStudio +- TagStudio can now traverse symlinks in your library folders +- TagStudio can now leverage [ripgrep](https://github.com/BurntSushi/ripgrep), a rust-based directory search tool, for faster library refreshing + - ripgrep must be [installed on your system](https://docs.tagstud.io/install/#ripgrep) and able to be located by TagStudio ##### Pull Requests: -- feat: add `.ts_ignore` pattern ignoring system by @CyanVoxel in #897 -- feat: replace extension exclusion system with `.ts_ignore` by @CyanVoxel in #1046 +- feat: add `.ts_ignore` pattern ignoring system by @CyanVoxel in #897 +- feat: replace extension exclusion system with `.ts_ignore` by @CyanVoxel in #1046 #### Library Information Window @@ -82,238 +189,238 @@ A new "Library Information" window has been added and is accessible under the "V ##### Pull Requests: -- feat: add LibraryInfoWindow with library statistics by @CyanVoxel in #1056 -- feat: add library cleanup screen and 'fix ignored files' window by @CyanVoxel in #1070 +- feat: add LibraryInfoWindow with library statistics by @CyanVoxel in #1056 +- feat: add library cleanup screen and 'fix ignored files' window by @CyanVoxel in #1070 #### Other Additions -- feat: add random sorting by @TheBobBobs in #1029 -- feat: add exr thumbnail support by @CyanVoxel in #1035 -- feat: add thumbnail generation toggle by @ZwodahS in #1057 -- feat: cli version argument by @HeikoWasTaken in #1060 -- feat: add setting to select splash screen by @CyanVoxel in #1077 - - Includes a new "'95" splash screen originally intended for the [9.5.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0) release +- feat: add random sorting by @TheBobBobs in #1029 +- feat: add exr thumbnail support by @CyanVoxel in #1035 +- feat: add thumbnail generation toggle by @ZwodahS in #1057 +- feat: cli version argument by @HeikoWasTaken in #1060 +- feat: add setting to select splash screen by @CyanVoxel in #1077 + - Includes a new "'95" splash screen originally intended for the [9.5.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0) release splash_selection_half ### Fixed -- fix: searching with internal tag ids ignores sorting order by @CyanVoxel in #1038 -- fix: folders with names of unlinked entries are linked by @purpletennisball in #1027 -- fix: parent tags in tag editor are uneditable by @purpletennisball in #1073 -- feat: auto mnemonics by @Computerdores in #1082 and #1083 +- fix: searching with internal tag ids ignores sorting order by @CyanVoxel in #1038 +- fix: folders with names of unlinked entries are linked by @purpletennisball in #1027 +- fix: parent tags in tag editor are uneditable by @purpletennisball in #1073 +- feat: auto mnemonics by @Computerdores in #1082 and #1083 ### Changed #### Performance -- perf: optimize sql for or queries by @TheBobBobs in #948 -- perf: Optimize db queries for preview panel by @TheBobBobs in #942 -- fix: add tags to selected entries in bulk not individually by @Computerdores in #1028 +- perf: optimize sql for or queries by @TheBobBobs in #948 +- perf: Optimize db queries for preview panel by @TheBobBobs in #942 +- fix: add tags to selected entries in bulk not individually by @Computerdores in #1028 #### Translations -- **Chinese** _(Traditional Han Script)_ by @tkiuvvv233 -- **French** updated by @Bamowen, @kitsumed -- **German** updated by @Livesi5e -- **Hungarian** updated by @smileyhead -- **Japanese** updated by wany-oh -- **Polish** updated by @FeatherPrince -- **Portuguese** updated by @SantosSi -- **Romanian** updated by @VLTNOgithub -- **Russian** updated by @Dott-rus -- **Spanish** updated by @JCC1998 -- **Swedish** updated by konto +- **Chinese** _(Traditional Han Script)_ by @tkiuvvv233 +- **French** updated by @Bamowen, @kitsumed +- **German** updated by @Livesi5e +- **Hungarian** updated by @smileyhead +- **Japanese** updated by wany-oh +- **Polish** updated by @FeatherPrince +- **Portuguese** updated by @SantosSi +- **Romanian** updated by @VLTNOgithub +- **Russian** updated by @Dott-rus +- **Spanish** updated by @JCC1998 +- **Swedish** updated by konto #### Internal Changes -- feat: swap IDs in tag_parents table by @HeikoWasTaken in #998 - - fix: swap parent and child logic for TAG_CHILDREN_QUERY by @CyanVoxel in #1064 -- fix(nix): fixup and rework, always use nixpkgs PySide/Qt by @xarvex in #1048 -- refactor: make cache_manager thread safe by @TheBobBobs in #1039 -- ci(tests): fix broken tests and add type hints by @CyanVoxel in #1062 -- refactor: store DB version inside `versions` table by @CyanVoxel in #1058 -- refactor: unwrap instead of assert not None by @Computerdores in #1068 -- chore(thumb_renderer): prepare for pillow_heif removing AVIF support by @xarvex in #1065 +- feat: swap IDs in tag_parents table by @HeikoWasTaken in #998 + - fix: swap parent and child logic for TAG_CHILDREN_QUERY by @CyanVoxel in #1064 +- fix(nix): fixup and rework, always use nixpkgs PySide/Qt by @xarvex in #1048 +- refactor: make cache_manager thread safe by @TheBobBobs in #1039 +- ci(tests): fix broken tests and add type hints by @CyanVoxel in #1062 +- refactor: store DB version inside `versions` table by @CyanVoxel in #1058 +- refactor: unwrap instead of assert not None by @Computerdores in #1068 +- chore(thumb_renderer): prepare for pillow_heif removing AVIF support by @xarvex in #1065 --- -## 9.5.3 [August 7th, 2025] +## 9.5.3 August 7th, 2025 ### Added -- Datetime fields by @Computerdores in #921, #946, and #926 -- Add date_format and hour_format settings by @JCC1998 in #904 -- Invert selection by @zfbx in #909 -- Show stems for extension-less files by @CyanVoxel in #899 -- Press enter when adding fields by @rsazra in #941 -- Option to change tag click behavior by @Computerdores in #945 -- Krita/Open Raster thumbnails by @mashed5894 in #985 -- Zoom keyboard shortcuts by @purpletennisball in #956 -- Clickable links in text fields by @TrigamDev in #924 +- Datetime fields by @Computerdores in #921, #946, and #926 +- Add date_format and hour_format settings by @JCC1998 in #904 +- Invert selection by @zfbx in #909 +- Show stems for extension-less files by @CyanVoxel in #899 +- Press enter when adding fields by @rsazra in #941 +- Option to change tag click behavior by @Computerdores in #945 +- Krita/Open Raster thumbnails by @mashed5894 in #985 +- Zoom keyboard shortcuts by @purpletennisball in #956 +- Clickable links in text fields by @TrigamDev in #924 ### Fixed -- Restore page navigation state by @Computerdores in #933 -- Proper error on unterminated quoted string by @Computerdores in #936 -- Creating new tag now refreshes the menu using the current search text by @purpletennisball in #939 -- Preview thumbnails don't scale as large as they could by @Computerdores in #1005 -- Add Nix path to FFmpeg locations on macOS by @thibmaek in #990 -- Use srctools instead of vtf2img to render vtf files by @CyanVoxel in #1014 +- Restore page navigation state by @Computerdores in #933 +- Proper error on unterminated quoted string by @Computerdores in #936 +- Creating new tag now refreshes the menu using the current search text by @purpletennisball in #939 +- Preview thumbnails don't scale as large as they could by @Computerdores in #1005 +- Add Nix path to FFmpeg locations on macOS by @thibmaek in #990 +- Use srctools instead of vtf2img to render vtf files by @CyanVoxel in #1014 ### Changed -- Add parent tags to `folders_to_tags` macro and start tagging at root folder by @rsazra in #940 -- Optimize page loading by @TheBobBobs in #954 -- Add arrow icons for navigation buttons by @CyanVoxel in #1016 -- Tweak media player style and behavior by @CyanVoxel in #1025 +- Add parent tags to `folders_to_tags` macro and start tagging at root folder by @rsazra in #940 +- Optimize page loading by @TheBobBobs in #954 +- Add arrow icons for navigation buttons by @CyanVoxel in #1016 +- Tweak media player style and behavior by @CyanVoxel in #1025 ### Translations -- **Chinese** _(Simplified Han Script)_ added and updated by @tkiuvvv233, Luoyu, @ngivanyh -- **Dutch** updated by @Pheubel -- **Filipino** updated by @searinminecraft -- **French** updated by @kitsumed -- **German** updated by @Livesi5e, @Stereo157E -- **Hungarian** updated by @smileyhead -- **Japanese** updated by wany-oh -- **Norwegian Bokmål** updated by @Neemek -- **Polish** updated by @FeatherPrince -- **Russian** updated by @Dott-rus, Utof, @maximmax42 -- **Spanish** updated by @JCC1998, Joan, Sunny, @danpg94 -- **Tamil** updated by @TamilNeram -- **Toki Pona** updated by @Math-Bee -- **Viossa** updated by @Nginearing +- **Chinese** _(Simplified Han Script)_ added and updated by @tkiuvvv233, Luoyu, @ngivanyh +- **Dutch** updated by @Pheubel +- **Filipino** updated by @searinminecraft +- **French** updated by @kitsumed +- **German** updated by @Livesi5e, @Stereo157E +- **Hungarian** updated by @smileyhead +- **Japanese** updated by wany-oh +- **Norwegian Bokmål** updated by @Neemek +- **Polish** updated by @FeatherPrince +- **Russian** updated by @Dott-rus, Utof, @maximmax42 +- **Spanish** updated by @JCC1998, Joan, Sunny, @danpg94 +- **Tamil** updated by @TamilNeram +- **Toki Pona** updated by @Math-Bee +- **Viossa** updated by @Nginearing ### Internal Changes -- refactor: type fixes and minor improvements to preview_thumb.py by @VasigaranAndAngel in #906 -- fix(test): Fix tests to pass on windows without disrupting other platforms by @zfbx in #903 -- chore(pyproject): version bumping/relaxing by @xarvex in #886 -- fix: tests were overwriting the settings.toml by @Computerdores in #928 -- fix(nix/package): override PySide6 if later version is being used by @xarvex in #917 -- refactor: split QtDriver into View and Controller to follow MVC model by @Computerdores in #935 -- refactor: resource_manager.py by @VasigaranAndAngel in #958 -- Type fixes to folders_to_tags.py, collage_icon.py and item_thumb.py by @VasigaranAndAngel in #959 -- Type fixes to preview_panel.py, progress.py, tag.py and tag_box.py by @VasigaranAndAngel in #961 -- Type improvements to landing.py and panel.py by @VasigaranAndAngel in #960 -- refactor(preview_panel): mvc split by @Computerdores in #952 -- refactor(preview_thumb): mvc split by @Computerdores in #978 -- refactor: type improvements for main_window.py by @VasigaranAndAngel in #957 -- fix(library): get_tag_by_name by @Computerdores in #1006 -- fix: ensure initial browsing state uses UI values by @CyanVoxel in #1008 -- refactor(tag_box): mvc split by @Computerdores in #1003 -- fix(ui): hide empty ProgressWidget cancel button by @CyanVoxel in #1011 -- fix(ui): fix audio waveform generation on numpy 2.3 by @CyanVoxel in #1013 -- refactor: replace remaining instances of logging with structlog by @CyanVoxel in #1012 -- fix: don't fail when posix env var is not present by @Computerdores in #1018 -- fix(ui): show correct thumb labels by @CyanVoxel in #1010 +- refactor: type fixes and minor improvements to preview_thumb.py by @VasigaranAndAngel in #906 +- fix(test): Fix tests to pass on windows without disrupting other platforms by @zfbx in #903 +- chore(pyproject): version bumping/relaxing by @xarvex in #886 +- fix: tests were overwriting the settings.toml by @Computerdores in #928 +- fix(nix/package): override PySide6 if later version is being used by @xarvex in #917 +- refactor: split QtDriver into View and Controller to follow MVC model by @Computerdores in #935 +- refactor: resource_manager.py by @VasigaranAndAngel in #958 +- Type fixes to folders_to_tags.py, collage_icon.py and item_thumb.py by @VasigaranAndAngel in #959 +- Type fixes to preview_panel.py, progress.py, tag.py and tag_box.py by @VasigaranAndAngel in #961 +- Type improvements to landing.py and panel.py by @VasigaranAndAngel in #960 +- refactor(preview_panel): mvc split by @Computerdores in #952 +- refactor(preview_thumb): mvc split by @Computerdores in #978 +- refactor: type improvements for main_window.py by @VasigaranAndAngel in #957 +- fix(library): get_tag_by_name by @Computerdores in #1006 +- fix: ensure initial browsing state uses UI values by @CyanVoxel in #1008 +- refactor(tag_box): mvc split by @Computerdores in #1003 +- fix(ui): hide empty ProgressWidget cancel button by @CyanVoxel in #1011 +- fix(ui): fix audio waveform generation on numpy 2.3 by @CyanVoxel in #1013 +- refactor: replace remaining instances of logging with structlog by @CyanVoxel in #1012 +- fix: don't fail when posix env var is not present by @Computerdores in #1018 +- fix(ui): show correct thumb labels by @CyanVoxel in #1010 ### Documentation -- Update CHANGELOG.md by @Math-Bee in #914 -- Add QT MVC structure to style guide by @Computerdores in #950 -- Fix wrong date on Changelog by @ugurozturk in #966 +- Update CHANGELOG.md by @Math-Bee in #914 +- Add QT MVC structure to style guide by @Computerdores in #950 +- Fix wrong date on Changelog by @ugurozturk in #966 --- -## 9.5.2 [March 31st, 2025] +## 9.5.2 March 31st, 2025 ### Added #### Search -- feat(ui): add setting to not display full filepaths by [@HermanKassler](https://github.com/HermanKassler) in [#841](https://github.com/TagStudioDev/TagStudio/pull/841) -- feat: add filename and path sorting by [@Computerdores](https://github.com/Computerdores) in [#842](https://github.com/TagStudioDev/TagStudio/pull/842) +- feat(ui): add setting to not display full filepaths by @HermanKassler in #841 +- feat: add filename and path sorting by @Computerdores in #842 #### Settings -- feat: new settings menu + settings backend by [@Computerdores](https://github.com/Computerdores) in [#859](https://github.com/TagStudioDev/TagStudio/pull/859) +- feat: new settings menu + settings backend by @Computerdores in #859 #### UI -- feat(ui): merge media controls by [@csponge](https://github.com/csponge) in [#805](https://github.com/TagStudioDev/TagStudio/pull/805) - - fix: Remove border from video preview top and left by [@zfbx](https://github.com/zfbx) in [#900](https://github.com/TagStudioDev/TagStudio/pull/900) -- feat(ui): add more default icons and file type equivalencies by [@CyanVoxel](https://github.com/CyanVoxel) in [#882](https://github.com/TagStudioDev/TagStudio/pull/882) -- ui: recent libraries list improvements by [@CyanVoxel](https://github.com/CyanVoxel) in [#881](https://github.com/TagStudioDev/TagStudio/pull/881) +- feat(ui): merge media controls by @csponge in #805 + - fix: Remove border from video preview top and left by @zfbx in #900 +- feat(ui): add more default icons and file type equivalencies by @CyanVoxel in #882 +- ui: recent libraries list improvements by @CyanVoxel in #881 #### Misc -- feat: provide a .desktop file by [@xarvex](https://github.com/xarvex) in [#870](https://github.com/TagStudioDev/TagStudio/pull/870) +- feat: provide a .desktop file by @xarvex in #870 ### Fixed -- fix: catch NotImplementedError for Float16 JPEG-XL files by [@CyanVoxel](https://github.com/CyanVoxel) in [#849](https://github.com/TagStudioDev/TagStudio/pull/849) -- fix(nix/package): account for GTK platform by [@xarvex](https://github.com/xarvex) in [#868](https://github.com/TagStudioDev/TagStudio/pull/868) -- fix: do not set palette for Linux-like systems that offer theming by [@xarvex](https://github.com/xarvex) in [#869](https://github.com/TagStudioDev/TagStudio/pull/869) -- fix(flake): remove pinned input, only consume in Nix shell by [@xarvex](https://github.com/xarvex) in [#872](https://github.com/TagStudioDev/TagStudio/pull/872) -- fix: stop ffmpeg cmd windows, refactor ffmpeg_checker by [@CyanVoxel](https://github.com/CyanVoxel) in [#855](https://github.com/TagStudioDev/TagStudio/pull/855) -- fix: hide mnemonics on macOS by [@CyanVoxel](https://github.com/CyanVoxel) in [#856](https://github.com/TagStudioDev/TagStudio/pull/856) -- fix: use UNION instead of UNION ALL by [@CyanVoxel](https://github.com/CyanVoxel) in [#877](https://github.com/TagStudioDev/TagStudio/pull/877) -- fix: remove unescaped ampersand from "about.description" by [@CyanVoxel](https://github.com/CyanVoxel) in [#885](https://github.com/TagStudioDev/TagStudio/pull/885) -- fix(ui): display 0 frame webp files in preview panel by [@CyanVoxel](https://github.com/CyanVoxel) in [64dc88a](https://github.com/TagStudioDev/TagStudio/commit/64dc88afa90bb11f3c9b74a2522f947370ce21db) -- fix: close pdf file object in thumb renderer by [@Computerdores](https://github.com/Computerdores) in [#893](https://github.com/TagStudioDev/TagStudio/pull/893) -- perf: improve responsiveness of GIF entries by [@Computerdores](https://github.com/Computerdores) in [#894](https://github.com/TagStudioDev/TagStudio/pull/894) -- fix(ui): seamlessly loop videos by [@CyanVoxel](https://github.com/CyanVoxel) in [#902](https://github.com/TagStudioDev/TagStudio/pull/902) +- fix: catch NotImplementedError for Float16 JPEG-XL files by @CyanVoxel in #849 +- fix(nix/package): account for GTK platform by @xarvex in #868 +- fix: do not set palette for Linux-like systems that offer theming by @xarvex in #869 +- fix(flake): remove pinned input, only consume in Nix shell by @xarvex in #872 +- fix: stop ffmpeg cmd windows, refactor ffmpeg_checker by @CyanVoxel in #855 +- fix: hide mnemonics on macOS by @CyanVoxel in #856 +- fix: use UNION instead of UNION ALL by @CyanVoxel in #877 +- fix: remove unescaped ampersand from "about.description" by @CyanVoxel in #885 +- fix(ui): display 0 frame webp files in preview panel by @CyanVoxel in 64dc88afa90bb11f3c9b74a2522f947370ce21db +- fix: close pdf file object in thumb renderer by @Computerdores in #893 +- perf: improve responsiveness of GIF entries by @Computerdores in #894 +- fix(ui): seamlessly loop videos by @CyanVoxel in #902 ### Internal Changes -- refactor!: change layout; import and build change by [@xarvex](https://github.com/xarvex) and [@CyanVoxel](https://github.com/CyanVoxel) in [#844](https://github.com/TagStudioDev/TagStudio/pull/844) -- fix: log all problems in translation test by [@Computerdores](https://github.com/Computerdores) in [#839](https://github.com/TagStudioDev/TagStudio/pull/839) -- refactor: split translation keys for about screen by [@CyanVoxel](https://github.com/CyanVoxel) in [#845](https://github.com/TagStudioDev/TagStudio/pull/845) -- feat(ci): development tooling refresh and split documentation by [@xarvex](https://github.com/xarvex) in [#867](https://github.com/TagStudioDev/TagStudio/pull/867) -- refactor: type hints and improvements in file_opener.py by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) in [#876](https://github.com/TagStudioDev/TagStudio/pull/876) -- build: update spec file to use proper pathex and datas paths by [@Leonard2](https://github.com/Leonard2) in [#895](https://github.com/TagStudioDev/TagStudio/pull/895) -- refactor: fix various missing and broken type hints@VasigaranAndAngel in [#901](https://github.com/TagStudioDev/TagStudio/pull/901) -- refactor: fix type hints and overrides in flowlayout.py by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) in [#880](https://github.com/TagStudioDev/TagStudio/pull/880) +- refactor!: change layout; import and build change by @xarvex and @CyanVoxel in #844 +- fix: log all problems in translation test by @Computerdores in #839 +- refactor: split translation keys for about screen by @CyanVoxel in #845 +- feat(ci): development tooling refresh and split documentation by @xarvex in #867 +- refactor: type hints and improvements in file_opener.py by @VasigaranAndAngel in #876 +- build: update spec file to use proper pathex and datas paths by @Leonard2 in #895 +- refactor: fix various missing and broken type hints@VasigaranAndAngel in #901 +- refactor: fix type hints and overrides in flowlayout.py by @VasigaranAndAngel in #880 ### Documentation -- docs: fix typos and grammar by [@Gawidev](https://github.com/Gawidev) in [#879](https://github.com/TagStudioDev/TagStudio/pull/879) -- docs: update `ThumbRenderer` source by [@emmanuel-ferdman](https://github.com/emmanuel-ferdman) in [#896](https://github.com/TagStudioDev/TagStudio/pull/896) +- docs: fix typos and grammar by @Gawidev in #879 +- docs: update `ThumbRenderer` source by @emmanuel-ferdman in #896 ### Translations -- **Filipino** updated by [@searinminecraft](https://github.com/searinminecraft) -- **French** updated by [@kitsumed](https://github.com/kitsumed) -- **German** updated by [@DontBlameMe99](https://github.com/DontBlameMe99), [@Computerdores](https://github.com/Computerdores) -- **Hungarian** updated by Szíjártó Levente Pál -- **Japanese** added by [@needledetector](https://github.com/needledetector) -- **Portuguese** _(Brazil)_ updated by [@viniciushelder](https://github.com/viniciushelder) -- **Russian** updated by werdi, [@Dott-rus](https://github.com/Dott-rus) -- **Spanish** updated by Joan, [@Nginearing](https://github.com/Nginearing) -- **Tamil** updated by [@TamilNeram](https://github.com/TamilNeram) -- **Toki Pona** updated by [@Math-Bee](https://github.com/Math-Bee) -- **Turkish** updated by [@Nyghl](https://github.com/Nyghl) +- **Filipino** updated by @searinminecraft +- **French** updated by @kitsumed +- **German** updated by @DontBlameMe99, @Computerdores +- **Hungarian** updated by Szíjártó Levente Pál +- **Japanese** added by @needledetector +- **Portuguese** _(Brazil)_ updated by @viniciushelder +- **Russian** updated by werdi, @Dott-rus +- **Spanish** updated by Joan, @Nginearing +- **Tamil** updated by @TamilNeram +- **Toki Pona** updated by @Math-Bee +- **Turkish** updated by @Nyghl --- -## 9.5.1 [March 6th, 2025] +## 9.5.1 March 6th, 2025 ### Fixed -- Fixed translations crashing the program and preventing it from being reopened ([#827](https://github.com/TagStudioDev/TagStudio/issues/827)) - - fix: restore `translate_formatted()` method as `format()` by [@CyanVoxel](https://github.com/CyanVoxel) in [#830](https://github.com/TagStudioDev/TagStudio/pull/830) - - tests: add tests for translations by [@Computerdores](https://github.com/Computerdores) in [#833](https://github.com/TagStudioDev/TagStudio/pull/833) - - fix(translations): fix invalid placeholders by [@CyanVoxel](https://github.com/CyanVoxel) in [#835](https://github.com/TagStudioDev/TagStudio/pull/835) -- Removed empty parentheses from the "About" screen title - - fix: separate about screen title from translations by [@CyanVoxel](https://github.com/CyanVoxel) in [#836](https://github.com/TagStudioDev/TagStudio/pull/836) +- Fixed translations crashing the program and preventing it from being reopened (#827) + - fix: restore `translate_formatted()` method as `format()` by @CyanVoxel in #830 + - tests: add tests for translations by @Computerdores in #833 + - fix(translations): fix invalid placeholders by @CyanVoxel in #835 +- Removed empty parentheses from the "About" screen title + - fix: separate about screen title from translations by @CyanVoxel in #836 ### Translations -- **French** updated by [@alessdangelo](https://github.com/alessdangelo), [@Bamowen](https://github.com/Bamowen), [@kitsumed](https://github.com/kitsumed) -- **German** updated by [@Thesacraft](https://github.com/Thesacraft) -- **Portuguese** _(Brazil)_ updated by [@viniciushelder](https://github.com/viniciushelder) -- **Russian** updated by werdei -- **Spanish** updated by [@JCC1998](https://github.com/JCC1998) +- **French** updated by @alessdangelo, @Bamowen, @kitsumed +- **German** updated by @Thesacraft +- **Portuguese** _(Brazil)_ updated by @viniciushelder +- **Russian** updated by werdei +- **Spanish** updated by @JCC1998 ### Documentation -- docs: fix category typo by [@salem404](https://github.com/salem404) in [#834](https://github.com/TagStudioDev/TagStudio/pull/834) +- docs: fix category typo by @salem404 in #834 --- -## 9.5.0 [March 3rd, 2025] +## 9.5.0 March 3rd, 2025 TagStudio 9.5 Banner @@ -323,80 +430,80 @@ A new "Library Information" window has been added and is accessible under the "V ##### Boolean Operators -- feat: implement query language by [@Computerdores](https://github.com/Computerdores) in [#606](https://github.com/TagStudioDev/TagStudio/pull/606) -- feat: optimize AND queries by [@Computerdores](https://github.com/Computerdores) in [#679](https://github.com/TagStudioDev/TagStudio/pull/679) +- feat: implement query language by @Computerdores in #606 +- feat: optimize AND queries by @Computerdores in #679 ##### Filetype, Mediatype, and Glob Path + Smartcase Searches -- fix: remove wildcard requirement for tags by [@Tyrannicodin](https://github.com/Tyrannicodin) in [#481](https://github.com/TagStudioDev/TagStudio/pull/481) -- feat: add filetype and mediatype searches by [@python357-1](https://github.com/python357-1) in [#575](https://github.com/TagStudioDev/TagStudio/pull/575) -- feat: make path search use globs by [@python357-1](https://github.com/python357-1) in [#582](https://github.com/TagStudioDev/TagStudio/pull/582) -- feat: implement search equivalence of "jpg" and "jpeg" filetypes by [@Computerdores](https://github.com/Computerdores) in [#649](https://github.com/TagStudioDev/TagStudio/pull/649) -- feat: add smartcase and globless path searches by [@CyanVoxel](https://github.com/CyanVoxel) in [#743](https://github.com/TagStudioDev/TagStudio/pull/743) +- fix: remove wildcard requirement for tags by @Tyrannicodin in #481 +- feat: add filetype and mediatype searches by @python357-1 in #575 +- feat: make path search use globs by @python357-1 in #582 +- feat: implement search equivalence of "jpg" and "jpeg" filetypes by @Computerdores in #649 +- feat: add smartcase and globless path searches by @CyanVoxel in #743 ##### Sortable Results -- feat: sort by "date added" in library by [@Computerdores](https://github.com/Computerdores) in [#674](https://github.com/TagStudioDev/TagStudio/pull/674) +- feat: sort by "date added" in library by @Computerdores in #674 ##### Autocomplete -- feat: add autocomplete for search engine by [@python357-1](https://github.com/python357-1) in [#586](https://github.com/TagStudioDev/TagStudio/pull/586) +- feat: add autocomplete for search engine by @python357-1 in #586 #### Replaced "Tag Fields" with Tag Categories Instead of tags needing to be added to a tag field type such as "Meta Tags", "Content Tags", or just the "Tags" field, tags are now added directly to file entries with no intermediary step. While tag field types offered a way to further organize tags, it was cumbersome, inflexible, and simply not fully fleshed out. Tag Categories offer all of the previous (intentional) functionality while greatly increasing the ease of use and customization. -- feat!: tag categories by [@CyanVoxel](https://github.com/CyanVoxel) in [#655](https://github.com/TagStudioDev/TagStudio/pull/655) +- feat!: tag categories by @CyanVoxel in #655 - Screenshot 2025-01-04 at 04 23 43 +Screenshot 2025-01-04 at 04 23 43 #### Thumbnails and File Previews ##### New Thumbnail Support -- feat: add svg thumbnail support (port [#442](https://github.com/TagStudioDev/TagStudio/pull/442)) by [@Tyrannicodin](https://github.com/Tyrannicodin) and [@CyanVoxel](https://github.com/CyanVoxel) in [#540](https://github.com/TagStudioDev/TagStudio/pull/540) -- feat: add pdf thumbnail support (port [#378](https://github.com/TagStudioDev/TagStudio/pull/378)) by [@Heiholf](https://github.com/Heiholf) and [@CyanVoxel](https://github.com/CyanVoxel) in [#543](https://github.com/TagStudioDev/TagStudio/pull/543) -- feat: add ePub thumbnail support (port [#387](https://github.com/TagStudioDev/TagStudio/pull/387)) by [@Heiholf](https://github.com/Heiholf) and [@CyanVoxel](https://github.com/CyanVoxel) in [#539](https://github.com/TagStudioDev/TagStudio/pull/539) -- feat: add OpenDocument thumbnail support (port [#366](https://github.com/TagStudioDev/TagStudio/pull/366)) by [@Joshua-Beatty](https://github.com/Joshua-Beatty) and [@CyanVoxel](https://github.com/CyanVoxel) in [#545](https://github.com/TagStudioDev/TagStudio/pull/545) -- feat: add JXL thumbnail and animated APNG + WEBP support (port [#344](https://github.com/TagStudioDev/TagStudio/pull/344) and partially port [#357](https://github.com/TagStudioDev/TagStudio/pull/357)) by [@BPplays](https://github.com/BPplays) and [@CyanVoxel](https://github.com/CyanVoxel) in [#549](https://github.com/TagStudioDev/TagStudio/pull/549) - - fix: catch ImportError for pillow_jxl module by [@CyanVoxel](https://github.com/CyanVoxel) in [a2f9685](https://github.com/TagStudioDev/TagStudio/commit/a2f9685bc0d744ea6f5334c6d2926aad3f6d375a) +- feat: add svg thumbnail support (port #442) by @Tyrannicodin and @CyanVoxel in #540 +- feat: add pdf thumbnail support (port #378) by @Heiholf and @CyanVoxel in #543 +- feat: add ePub thumbnail support (port #387) by @Heiholf and @CyanVoxel in #539 +- feat: add OpenDocument thumbnail support (port #366) by @Joshua-Beatty and @CyanVoxel in #545 +- feat: add JXL thumbnail and animated APNG + WEBP support (port #344 and partially port #357) by @BPplays and @CyanVoxel in #549 + - fix: catch ImportError for pillow_jxl module by @CyanVoxel in a2f9685bc0d744ea6f5334c6d2926aad3f6d375a ##### Audio Playback -- feat: audio playback by [@csponge](https://github.com/csponge) in [#576](https://github.com/TagStudioDev/TagStudio/pull/576) - - feat(ui): add audio volume slider by [@SkeleyM](https://github.com/SkeleyM) in [#691](https://github.com/TagStudioDev/TagStudio/pull/691) +- feat: audio playback by @csponge in #576 + - feat(ui): add audio volume slider by @SkeleyM in #691 ##### Thumbnail Caching -- feat(ui): add thumbnail caching by [@CyanVoxel](https://github.com/CyanVoxel) in [#694](https://github.com/TagStudioDev/TagStudio/pull/694) +- feat(ui): add thumbnail caching by @CyanVoxel in #694 #### Tags ##### Delete Tags _(Finally!)_ -- feat: remove and create tags from tag database panel by [@DandyDev01](https://github.com/DandyDev01) in [#569](https://github.com/TagStudioDev/TagStudio/pull/569) +- feat: remove and create tags from tag database panel by @DandyDev01 in #569 ##### Custom User-Created Tag Colors Create your own custom tag colors via the new Tag Color Manager! Tag colors are assigned a namespace (group) and include a name, primary color, and optional secondary color. By default the secondary color is used for the tag text color, but this can also be toggled to apply to the border color as well! -- feat(ui)!: user-created tag colors@CyanVoxel in [#801](https://github.com/TagStudioDev/TagStudio/pull/801) +- feat(ui)!: user-created tag colors by @CyanVoxel in #801 - - + + ##### New Tag Colors + UI -- feat: expanded tag color system by [@CyanVoxel](https://github.com/CyanVoxel) in [#709](https://github.com/TagStudioDev/TagStudio/pull/709) -- fix(ui): use correct pink tag color by [@CyanVoxel](https://github.com/CyanVoxel) in [431efe4](https://github.com/TagStudioDev/TagStudio/commit/431efe4fe93213141c763e59ca9887215766fd42) -- fix(ui): use consistent tag outline colors by [@CyanVoxel](https://github.com/CyanVoxel) in [020a73d](https://github.com/TagStudioDev/TagStudio/commit/020a73d095c74283d6c80426d3c3db8874409952) +- feat: expanded tag color system by @CyanVoxel in #709 +- fix(ui): use correct pink tag color by @CyanVoxel in 431efe4fe93213141c763e59ca9887215766fd42 +- fix(ui): use consistent tag outline colors by @CyanVoxel in 020a73d095c74283d6c80426d3c3db8874409952 Screenshot 2025-01-04 at 04 23 43 ##### New Tag Alias UI -- fix: preview panel aliases not staying up to date with database by [@DandyDev01](https://github.com/DandyDev01) in [#641](https://github.com/TagStudioDev/TagStudio/pull/641) -- fix: subtags/parent tags & aliases update the UI for building a tag by [@DandyDev01](https://github.com/DandyDev01) in [#534](https://github.com/TagStudioDev/TagStudio/pull/534) +- fix: preview panel aliases not staying up to date with database by @DandyDev01 in #641 +- fix: subtags/parent tags & aliases update the UI for building a tag by @DandyDev01 in #534 #### Translations @@ -404,50 +511,50 @@ TagStudio now has official translation support! Head to the new settings panel a Translation hosting generously provided by [Weblate](https://weblate.org/en/). Check out our [project page](https://hosted.weblate.org/projects/tagstudio/) to help translate TagStudio! Thank you to everyone who's helped contribute to the translations so far! -- translations: add string tokens for en.json by [@Bamowen](https://github.com/Bamowen) in [#507](https://github.com/TagStudioDev/TagStudio/pull/507) -- feat: translations by [@Computerdores](https://github.com/Computerdores) in [#662](https://github.com/TagStudioDev/TagStudio/pull/662) -- feat(ui): add language setting by [@CyanVoxel](https://github.com/CyanVoxel) in [#803](https://github.com/TagStudioDev/TagStudio/pull/803) +- translations: add string tokens for en.json by @Bamowen in #507 +- feat: translations by @Computerdores in #662 +- feat(ui): add language setting by @CyanVoxel in #803 Initial Languages: -- **Chinese** _(Traditional Han Script)_ by [@brisu](https://github.com/brisu) -- **Dutch** by [@Pheubel](https://github.com/Pheubel) -- **Filipino** by [@searinminecraft](https://github.com/searinminecraft) -- **French** by [@Bamowen](https://github.com/Bamowen), [@alessdangelo](https://github.com/alessdangelo), [@kitsumed](https://github.com/kitsumed), Obscaeris -- **German** by [@Ryussei](https://github.com/Ryussei), [@Computerdores](https://github.com/Computerdores), Aaron M, [@JoeJoeTV](https://github.com/JoeJoeTV), [@Kurty00](https://github.com/Kurty00) -- **Hungarian** by [@smileyhead](https://github.com/smileyhead) -- **Norwegian Bokmål** by [@comradekingu](https://github.com/comradekingu) -- **Polish** by Anonymous -- **Portuguese** _(Brazil)_ by [@LoboMetalurgico](https://github.com/LoboMetalurgico), [@SpaceFox1](https://github.com/SpaceFox1), [@DaviMarquezeli](https://github.com/DaviMarquezeli), [@viniciushelder](https://github.com/viniciushelder), Alexander Lennart Formiga Johnsson -- **Russian** by [@The-Stolas](https://github.com/The-Stolas) -- **Spanish** by [@gallegonovato](https://github.com/gallegonovato), [@Nginearing](https://github.com/Nginearing), [@noceno](https://github.com/noceno) -- **Swedish** by [@adampawelec](https://github.com/adampawelec), [@mashed5894](https://github.com/mashed5894) -- **Tamil** by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) -- **Toki Pona** by [@goldstargloww](https://github.com/goldstargloww) -- **Turkish** by [@Nyghl](https://github.com/Nyghl) +- **Chinese** _(Traditional Han Script)_ by @brisu +- **Dutch** by @Pheubel +- **Filipino** by @searinminecraft +- **French** by @Bamowen, @alessdangelo, @kitsumed, Obscaeris +- **German** by @Ryussei, @Computerdores, Aaron M, @JoeJoeTV, @Kurty00 +- **Hungarian** by @smileyhead +- **Norwegian Bokmål** by @comradekingu +- **Polish** by Anonymous +- **Portuguese** _(Brazil)_ by @LoboMetalurgico, @SpaceFox1, @DaviMarquezeli, @viniciushelder, Alexander Lennart Formiga Johnsson +- **Russian** by @The-Stolas +- **Spanish** by @gallegonovato, @Nginearing, @noceno +- **Swedish** by @adampawelec, @mashed5894 +- **Tamil** by @VasigaranAndAngel +- **Toki Pona** by @goldstargloww +- **Turkish** by @Nyghl #### Miscellaneous -- feat: about section by [@mashed5894](https://github.com/mashed5894) in [#712](https://github.com/TagStudioDev/TagStudio/pull/712) -- feat(ui): add configurable splash screens by [@CyanVoxel](https://github.com/CyanVoxel) in [#703](https://github.com/TagStudioDev/TagStudio/pull/703) -- feat(ui): show filenames in thumbnail grid by [@CyanVoxel](https://github.com/CyanVoxel) in [#633](https://github.com/TagStudioDev/TagStudio/pull/633) -- feat(about): clickable links to docs/discord/etc in about modal by [@SkeleyM](https://github.com/SkeleyM) in [#799](https://github.com/TagStudioDev/TagStudio/pull/799) +- feat: about section by @mashed5894 in #712 +- feat(ui): add configurable splash screens by @CyanVoxel in #703 +- feat(ui): show filenames in thumbnail grid by @CyanVoxel in #633 +- feat(about): clickable links to docs/discord/etc in about modal by @SkeleyM in #799 ### Fixed -- fix(ui): display all tags in panel during empty search by [@samuellieberman](https://github.com/samuellieberman) in [#328](https://github.com/TagStudioDev/TagStudio/pull/328) -- fix: avoid `KeyError` in `add_folders_to_tree()` (fix [#346](https://github.com/TagStudioDev/TagStudio/issues/346)) by [@CyanVoxel](https://github.com/CyanVoxel) in [#347](https://github.com/TagStudioDev/TagStudio/pull/347) -- fix: error on closing library by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#484](https://github.com/TagStudioDev/TagStudio/pull/484) -- fix: resolution info [#550](https://github.com/TagStudioDev/TagStudio/issues/550) by [@Roc25](https://github.com/Roc25) in [#551](https://github.com/TagStudioDev/TagStudio/pull/551) -- fix: remove queued thumnail jobs when closing library by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#583](https://github.com/TagStudioDev/TagStudio/pull/583) -- fix: use absolute ffprobe path on macos (Fix [#511](https://github.com/TagStudioDev/TagStudio/issues/511)) by [@CyanVoxel](https://github.com/CyanVoxel) in [#629](https://github.com/TagStudioDev/TagStudio/pull/629) -- fix(ui): prevent duplicate parent tags in UI by [@SkeleyM](https://github.com/SkeleyM) in [#665](https://github.com/TagStudioDev/TagStudio/pull/665) -- fix: fix -o flag not working if path has whitespace around it by [@python357-1](https://github.com/python357-1) in [#670](https://github.com/TagStudioDev/TagStudio/pull/670) -- fix: better file opening compatibility with non-ascii filenames by [@SkeleyM](https://github.com/SkeleyM) in [#667](https://github.com/TagStudioDev/TagStudio/pull/667) -- fix: restore environment before launching external programs by [@mashed5894](https://github.com/mashed5894) in [#707](https://github.com/TagStudioDev/TagStudio/pull/707) -- fix: have pydub use known ffmpeg + ffprobe locations by [@CyanVoxel](https://github.com/CyanVoxel) in [#724](https://github.com/TagStudioDev/TagStudio/pull/724) -- fix: add ".DS_Store" to `GLOBAL_IGNORE_SET` by [@CyanVoxel](https://github.com/CyanVoxel) in [b72a2f2](https://github.com/TagStudioDev/TagStudio/commit/b72a2f233141db4db6aa6be8796b626ebd3f0756) -- fix: don't add ".\_" files to libraries by [@CyanVoxel](https://github.com/CyanVoxel) in [eb1f634](https://github.com/TagStudioDev/TagStudio/commit/eb1f634d386cd8a5ecee1e6ff6a0b7d8811550fa) +- fix(ui): display all tags in panel during empty search by @samuellieberman in #328 +- fix: avoid `KeyError` in `add_folders_to_tree()` (fix #346) by @CyanVoxel in #347 +- fix: error on closing library by @yedpodtrzitko in #484 +- fix: resolution info #550 by @Roc25 in #551 +- fix: remove queued thumnail jobs when closing library by @yedpodtrzitko in #583 +- fix: use absolute ffprobe path on macos (Fix #511) by @CyanVoxel in #629 +- fix(ui): prevent duplicate parent tags in UI by @SkeleyM in #665 +- fix: fix -o flag not working if path has whitespace around it by @python357-1 in #670 +- fix: better file opening compatibility with non-ascii filenames by @SkeleyM in #667 +- fix: restore environment before launching external programs by @mashed5894 in #707 +- fix: have pydub use known ffmpeg + ffprobe locations by @CyanVoxel in #724 +- fix: add ".DS_Store" to `GLOBAL_IGNORE_SET` by @CyanVoxel in b72a2f233141db4db6aa6be8796b626ebd3f0756 +- fix: don't add ".\_" files to libraries by @CyanVoxel in eb1f634d386cd8a5ecee1e6ff6a0b7d8811550fa ### Changed @@ -455,82 +562,82 @@ Initial Languages: This was the main focus of this update, and where the majority of development time and resources have been spent since v9.4. These changes include everything that was done to migrate from the JSON format to SQLite starting from the initial SQLite PR, while re-implementing every feature from v9.4 as the initial SQLite PR was based on v9.3.x at the time. -- refactor!: use SQLite and SQLAlchemy for database backend by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#332](https://github.com/TagStudioDev/TagStudio/pull/332) -- feat: make search results more ergonomic by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#498](https://github.com/TagStudioDev/TagStudio/pull/498) -- feat: store `Entry` suffix separately by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#503](https://github.com/TagStudioDev/TagStudio/pull/503) -- feat: port thumbnail ([#390](https://github.com/TagStudioDev/TagStudio/pull/390)) and related features to v9.5 by [@CyanVoxel](https://github.com/CyanVoxel) in [#522](https://github.com/TagStudioDev/TagStudio/pull/522) -- fix: don't check db version with new library by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#536](https://github.com/TagStudioDev/TagStudio/pull/536) -- fix(ui): update ui when removing fields by [@DandyDev01](https://github.com/DandyDev01) in [#560](https://github.com/TagStudioDev/TagStudio/pull/560) -- feat(parity): backend for aliases and parent tags by [@DandyDev01](https://github.com/DandyDev01) in [#596](https://github.com/TagStudioDev/TagStudio/pull/596) -- fix: "open in explorer" opens correct folder by [@KirilBourakov](https://github.com/KirilBourakov) in [#603](https://github.com/TagStudioDev/TagStudio/pull/603) -- fix: ui/ux parity fixes for thumbnails and files by [@CyanVoxel](https://github.com/CyanVoxel) in [#608](https://github.com/TagStudioDev/TagStudio/pull/608) -- feat(parity): migrate json libraries to sqlite by [@CyanVoxel](https://github.com/CyanVoxel) in [#604](https://github.com/TagStudioDev/TagStudio/pull/604) -- fix: clear all setting values when opening a library by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) in [#622](https://github.com/TagStudioDev/TagStudio/pull/622) -- fix: remove/rework windows path tests by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) in [#625](https://github.com/TagStudioDev/TagStudio/pull/625) -- fix: add check to see if library is loaded in filter_items by [@Roc25](https://github.com/Roc25) in [#547](https://github.com/TagStudioDev/TagStudio/pull/547) -- fix: multiple macro errors by [@Computerdores](https://github.com/Computerdores) in [#612](https://github.com/TagStudioDev/TagStudio/pull/612) -- fix: don't allow blank tag alias values in db by [@CyanVoxel](https://github.com/CyanVoxel) in [#628](https://github.com/TagStudioDev/TagStudio/pull/628) -- feat: Reimplement drag drop files on sql migration by [@seakrueger](https://github.com/seakrueger) in [#528](https://github.com/TagStudioDev/TagStudio/pull/528) -- fix: stop sqlite db from being updated while running tests by [@python357-1](https://github.com/python357-1) in [#648](https://github.com/TagStudioDev/TagStudio/pull/648) -- fix: enter/return adds top result tag by [@SkeleyM](https://github.com/SkeleyM) in [#651](https://github.com/TagStudioDev/TagStudio/pull/651) -- fix: show correct unlinked files count by [@SkeleyM](https://github.com/SkeleyM) in [#653](https://github.com/TagStudioDev/TagStudio/pull/653) -- feat: implement parent tag search by [@Computerdores](https://github.com/Computerdores) in [#673](https://github.com/TagStudioDev/TagStudio/pull/673) -- fix: only close add tag menu with no search by [@SkeleyM](https://github.com/SkeleyM) in [#685](https://github.com/TagStudioDev/TagStudio/pull/685) -- fix: drag and drop no longer resets by [@SkeleyM](https://github.com/SkeleyM) in [#710](https://github.com/TagStudioDev/TagStudio/pull/710) -- feat(ui): port "create and add tag" to main branch by [@SkeleyM](https://github.com/SkeleyM) in [#711](https://github.com/TagStudioDev/TagStudio/pull/711) -- fix: don't add default title field, use proper phrasing for adding files by [@CyanVoxel](https://github.com/CyanVoxel) in [#701](https://github.com/TagStudioDev/TagStudio/pull/701) -- fix: preview panel + main window fixes and optimizations by [@CyanVoxel](https://github.com/CyanVoxel) in [#700](https://github.com/TagStudioDev/TagStudio/pull/700) -- fix: sort tag results by [@mashed5894](https://github.com/mashed5894) in [#721](https://github.com/TagStudioDev/TagStudio/pull/721) -- fix: restore opening last library on startup by [@SkeleyM](https://github.com/SkeleyM) in [#729](https://github.com/TagStudioDev/TagStudio/pull/729) -- fix(ui): don't always create tag on enter by [@SkeleyM](https://github.com/SkeleyM) in [#731](https://github.com/TagStudioDev/TagStudio/pull/731) -- fix: use tag aliases in tag search by [@CyanVoxel](https://github.com/CyanVoxel) in [#726](https://github.com/TagStudioDev/TagStudio/pull/726) -- fix: keep initial id order in `get_entries_full()` by [@CyanVoxel](https://github.com/CyanVoxel) in [#736](https://github.com/TagStudioDev/TagStudio/pull/736) -- fix: always catch db mismatch by [@CyanVoxel](https://github.com/CyanVoxel) in [#738](https://github.com/TagStudioDev/TagStudio/pull/738) -- fix: relink unlinked entry to existing entry without sql error by [@mashed5894](https://github.com/mashed5894) in [#730](https://github.com/TagStudioDev/TagStudio/issues/730) -- fix: refactor and fix bugs with missing_files.py by [@CyanVoxel](https://github.com/CyanVoxel) in [#739](https://github.com/TagStudioDev/TagStudio/pull/739) -- fix: dragging files references correct entry IDs [@CyanVoxel](https://github.com/CyanVoxel) in [44ff17c](https://github.com/TagStudioDev/TagStudio/commit/44ff17c0b3f05570e356c112f005dbc14c7cc05d) -- ui: port splash screen from Alpha-v9.4 by [@CyanVoxel](https://github.com/CyanVoxel) in [af760ee](https://github.com/TagStudioDev/TagStudio/commit/af760ee61a523c84bab0fb03a68d7465866d0e05) -- fix: tags created from tag database now add aliases by [@CyanVoxel](https://github.com/CyanVoxel) in [2903dd2](https://github.com/TagStudioDev/TagStudio/commit/2903dd22c45c02498687073d075bb88886de6b62) -- fix: check for tag name parity during JSON migration by [@CyanVoxel](https://github.com/CyanVoxel) in [#748](https://github.com/TagStudioDev/TagStudio/pull/748) -- feat(ui): re-implement tag display names on sql by [@CyanVoxel](https://github.com/CyanVoxel) in [#747](https://github.com/TagStudioDev/TagStudio/pull/747) -- fix(ui): restore Windows accent color on PySide 6.8.0.1 by [@CyanVoxel](https://github.com/CyanVoxel) in [#755](https://github.com/TagStudioDev/TagStudio/pull/755) -- fix(ui): (mostly) fix right-click search option on tags by [@CyanVoxel](https://github.com/CyanVoxel) in [#756](https://github.com/TagStudioDev/TagStudio/pull/756) -- feat: copy/paste fields and tags by [@mashed5894](https://github.com/mashed5894) in [#722](https://github.com/TagStudioDev/TagStudio/pull/722) -- perf: optimize query methods and reduce preview panel updates by [@CyanVoxel](https://github.com/CyanVoxel) in [#794](https://github.com/TagStudioDev/TagStudio/pull/794) -- feat: port file trashing ([#409](https://github.com/TagStudioDev/TagStudio/pull/409)) to v9.5 by [@CyanVoxel](https://github.com/CyanVoxel) in [#792](https://github.com/TagStudioDev/TagStudio/pull/792) -- fix: prevent future library versions from being opened by [@CyanVoxel](https://github.com/CyanVoxel) in [bcf3b2f](https://github.com/TagStudioDev/TagStudio/commit/bcf3b2f96bc8b876ca4b0c1d1882ce14a190f249) +- refactor!: use SQLite and SQLAlchemy for database backend by @yedpodtrzitko in #332 +- feat: make search results more ergonomic by @yedpodtrzitko in #498 +- feat: store `Entry` suffix separately by @yedpodtrzitko in #503 +- feat: port thumbnail (#390) and related features to v9.5 by @CyanVoxel in #522 +- fix: don't check db version with new library by @yedpodtrzitko in #536 +- fix(ui): update ui when removing fields by @DandyDev01 in #560 +- feat(parity): backend for aliases and parent tags by @DandyDev01 in #596 +- fix: "open in explorer" opens correct folder by @KirilBourakov in #603 +- fix: ui/ux parity fixes for thumbnails and files by @CyanVoxel in #608 +- feat(parity): migrate json libraries to sqlite by @CyanVoxel in #604 +- fix: clear all setting values when opening a library by @VasigaranAndAngel in #622 +- fix: remove/rework windows path tests by @VasigaranAndAngel in #625 +- fix: add check to see if library is loaded in filter_items by @Roc25 in #547 +- fix: multiple macro errors by @Computerdores in #612 +- fix: don't allow blank tag alias values in db by @CyanVoxel in #628 +- feat: Reimplement drag drop files on sql migration by @seakrueger in #528 +- fix: stop sqlite db from being updated while running tests by @python357-1 in #648 +- fix: enter/return adds top result tag by @SkeleyM in #651 +- fix: show correct unlinked files count by @SkeleyM in #653 +- feat: implement parent tag search by @Computerdores in #673 +- fix: only close add tag menu with no search by @SkeleyM in #685 +- fix: drag and drop no longer resets by @SkeleyM in #710 +- feat(ui): port "create and add tag" to main branch by @SkeleyM in #711 +- fix: don't add default title field, use proper phrasing for adding files by @CyanVoxel in #701 +- fix: preview panel + main window fixes and optimizations by @CyanVoxel in #700 +- fix: sort tag results by @mashed5894 in #721 +- fix: restore opening last library on startup by @SkeleyM in #729 +- fix(ui): don't always create tag on enter by @SkeleyM in #731 +- fix: use tag aliases in tag search by @CyanVoxel in #726 +- fix: keep initial id order in `get_entries_full()` by @CyanVoxel in #736 +- fix: always catch db mismatch by @CyanVoxel in #738 +- fix: relink unlinked entry to existing entry without sql error by @mashed5894 in #730 +- fix: refactor and fix bugs with missing_files.py by @CyanVoxel in #739 +- fix: dragging files references correct entry IDs @CyanVoxel in 44ff17c0b3f05570e356c112f005dbc14c7cc05d +- ui: port splash screen from Alpha-v9.4 by @CyanVoxel in af760ee61a523c84bab0fb03a68d7465866d0e05 +- fix: tags created from tag database now add aliases by @CyanVoxel in 2903dd22c45c02498687073d075bb88886de6b62 +- fix: check for tag name parity during JSON migration by @CyanVoxel in #748 +- feat(ui): re-implement tag display names on sql by @CyanVoxel in #747 +- fix(ui): restore Windows accent color on PySide 6.8.0.1 by @CyanVoxel in #755 +- fix(ui): (mostly) fix right-click search option on tags by @CyanVoxel in #756 +- feat: copy/paste fields and tags by @mashed5894 in #722 +- perf: optimize query methods and reduce preview panel updates by @CyanVoxel in #794 +- feat: port file trashing (#409) to v9.5 by @CyanVoxel in #792 +- fix: prevent future library versions from being opened by @CyanVoxel in bcf3b2f96bc8b876ca4b0c1d1882ce14a190f249 #### UI/UX -- feat(ui): pre-select default tag name in `BuildTagPanel` by [@Cool-Game-Dev](https://github.com/Cool-Game-Dev) in [#592](https://github.com/TagStudioDev/TagStudio/pull/592) -- feat(ui): keyboard navigation for editing tags by [@Computerdores](https://github.com/Computerdores) in [#407](https://github.com/TagStudioDev/TagStudio/pull/407) -- feat(ui): use tag query as default new tag name by [@CyanVoxel](https://github.com/CyanVoxel) in [29c0dfd](https://github.com/TagStudioDev/TagStudio/commit/29c0dfdb2d88e8f473e27c7f1fe7ede6e5bd0feb) -- feat(ui): shortcut to add tags to selected entries; change click behavior of tags to edit by [@CyanVoxel](https://github.com/CyanVoxel) in [#749](https://github.com/TagStudioDev/TagStudio/pull/749) -- fix(ui): use consistent dark mode colors for all systems by [@CyanVoxel](https://github.com/CyanVoxel) in [#752](https://github.com/TagStudioDev/TagStudio/pull/752) -- fix(ui): use camera white balance for raw images by [@CyanVoxel](https://github.com/CyanVoxel) in [6ee5304](https://github.com/TagStudioDev/TagStudio/commit/6ee5304b52f217af0f5df543fcb389649203d6b2) -- Mixed field editing has been limited due to various bugs in both the JSON and SQL implementations. This will be re-implemented in a future release. -- fix(ui): improve tagging ux by [@CyanVoxel](https://github.com/CyanVoxel) in [#633](https://github.com/TagStudioDev/TagStudio/pull/633) -- fix(ui): hide library actions when no library is open by [@CyanVoxel](https://github.com/CyanVoxel) in [#787](https://github.com/TagStudioDev/TagStudio/pull/787) -- refactor(ui): recycle tag list in TagSearchPanel by [@CyanVoxel](https://github.com/CyanVoxel) in [#788](https://github.com/TagStudioDev/TagStudio/pull/788) - - feat(ui): add tag view limit dropdown -- fix(ui): expand usage of esc and enter for modals by [@CyanVoxel](https://github.com/CyanVoxel) in [#793](https://github.com/TagStudioDev/TagStudio/pull/793) +- feat(ui): pre-select default tag name in `BuildTagPanel` by @Cool-Game-Dev in #592 +- feat(ui): keyboard navigation for editing tags by @Computerdores in #407 +- feat(ui): use tag query as default new tag name by @CyanVoxel in 29c0dfdb2d88e8f473e27c7f1fe7ede6e5bd0feb +- feat(ui): shortcut to add tags to selected entries; change click behavior of tags to edit by @CyanVoxel in #749 +- fix(ui): use consistent dark mode colors for all systems by @CyanVoxel in #752 +- fix(ui): use camera white balance for raw images by @CyanVoxel in 6ee5304b52f217af0f5df543fcb389649203d6b2 +- Mixed field editing has been limited due to various bugs in both the JSON and SQL implementations. This will be re-implemented in a future release. +- fix(ui): improve tagging ux by @CyanVoxel in #633 +- fix(ui): hide library actions when no library is open by @CyanVoxel in #787 +- refactor(ui): recycle tag list in TagSearchPanel by @CyanVoxel in #788 + - feat(ui): add tag view limit dropdown +- fix(ui): expand usage of esc and enter for modals by @CyanVoxel in #793 #### Performance -- feat: improve performance of "Delete Missing Entries" by [@Toby222](https://github.com/Toby222) and [@Computerdores](https://github.com/Computerdores) in [#696](https://github.com/TagStudioDev/TagStudio/pull/696) +- feat: improve performance of "Delete Missing Entries" by @Toby222 and @Computerdores in #696 #### Internal Changes -- refactor: combine open launch args by [@UnusualEgg](https://github.com/UnusualEgg) in [#364](https://github.com/TagStudioDev/TagStudio/pull/364) -- feat: add date_created, date_modified, and date_added columns to entries table by [@CyanVoxel](https://github.com/CyanVoxel) in [#740](https://github.com/TagStudioDev/TagStudio/pull/740) +- refactor: combine open launch args by @UnusualEgg in #364 +- feat: add date_created, date_modified, and date_added columns to entries table by @CyanVoxel in #740 --- -## 9.5.0-pr4 [February 17th, 2025] +## 9.5.0-pr4 February 17th, 2025 ### Added -#### Custom User-Created Tag Colors ([@CyanVoxel](https://github.com/CyanVoxel) in [#801](https://github.com/TagStudioDev/TagStudio/pull/801)) +#### Custom User-Created Tag Colors (@CyanVoxel in #801) Create your own custom tag colors via the new Tag Color Manager! Tag colors are assigned a namespace (group) and include a name, primary color, and optional secondary color. By default the secondary color is used for the tag text color, but this can also be toggled to apply to the border color as well! @@ -543,46 +650,46 @@ TagStudio now has official translation support! Head to the new settings panel a Translation hosting generously provided by [Weblate](https://weblate.org/en/). Check out our [project page](https://hosted.weblate.org/projects/tagstudio/) to help translate TagStudio! Thank you to everyone who's helped contribute to the translations so far! -- translations: add string tokens for en.json by [@Bamowen](https://github.com/Bamowen) in [#507](https://github.com/TagStudioDev/TagStudio/pull/507) -- feat: translations by [@Computerdores](https://github.com/Computerdores) in [#662](https://github.com/TagStudioDev/TagStudio/pull/662) -- feat(ui): add language setting by [@CyanVoxel](https://github.com/CyanVoxel) in [#803](https://github.com/TagStudioDev/TagStudio/pull/803) +- translations: add string tokens for en.json by @Bamowen in #507 +- feat: translations by @Computerdores in #662 +- feat(ui): add language setting by @CyanVoxel in #803 Initial Languages: -- Chinese (Traditional) (68%) - - [@brisu](https://github.com/brisu) -- Dutch (35%) - - [@Pheubel](https://github.com/Pheubel) -- Filipino (15%) - - [@searinminecraft](https://github.com/searinminecraft) -- French (89%) - - [@Bamowen](https://github.com/Bamowen), [@alessdangelo](https://github.com/alessdangelo), [@kitsumed](https://github.com/kitsumed), Obscaeris -- German (73%) - - [@Ryussei](https://github.com/Ryussei), [@Computerdores](https://github.com/Computerdores), Aaron M -- Hungarian (89%) - - [@smileyhead](https://github.com/smileyhead) -- Norwegian Bokmål (16%) - - [@comradekingu](https://github.com/comradekingu) -- Polish (76%) - - Anonymous -- Portuguese (Brazil) (22%) - - [@LoboMetalurgico](https://github.com/LoboMetalurgico), [@SpaceFox1](https://github.com/SpaceFox1) -- Russian (22%) - - [@The-Stolas](https://github.com/The-Stolas) -- Spanish (46%) - - [@gallegonovato](https://github.com/gallegonovato), [@Nginearing](https://github.com/Nginearing), [@noceno](https://github.com/noceno) -- Swedish (24%) - - [@adampawelec](https://github.com/adampawelec), [@mashed5894](https://github.com/mashed5894) -- Tamil (22%) - - [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) -- Toki Pona (32%) - - [@goldstargloww](https://github.com/goldstargloww) -- Turkish (22%) - - [@Nyghl](https://github.com/Nyghl) +- Chinese (Traditional) (68%) + - @brisu +- Dutch (35%) + - @Pheubel +- Filipino (15%) + - @searinminecraft +- French (89%) + - @Bamowen, @alessdangelo, @kitsumed, Obscaeris +- German (73%) + - @Ryussei, @Computerdores, Aaron M +- Hungarian (89%) + - @smileyhead +- Norwegian Bokmål (16%) + - @comradekingu +- Polish (76%) + - Anonymous +- Portuguese (Brazil) (22%) + - @LoboMetalurgico, @SpaceFox1 +- Russian (22%) + - @The-Stolas +- Spanish (46%) + - @gallegonovato, @Nginearing, @noceno +- Swedish (24%) + - @adampawelec, @mashed5894 +- Tamil (22%) + - @VasigaranAndAngel +- Toki Pona (32%) + - @goldstargloww +- Turkish (22%) + - @Nyghl ### Fixed -- feat(about): clickable links to docs/discord/etc in about modal by [@SkeleyM](https://github.com/SkeleyM) in [#799](https://github.com/TagStudioDev/TagStudio/pull/799) +- feat(about): clickable links to docs/discord/etc in about modal by @SkeleyM in #799 ### Internal Changes @@ -590,107 +697,107 @@ This release increases the internal `DB_VERSION` to 8. Libraries created with th --- -## 9.5.0-pr3 [February 10th, 2025] +## 9.5.0-pr3 February 10th, 2025 ### Added -##### [#743](https://github.com/TagStudioDev/TagStudio/pull/743) by [@CyanVoxel](https://github.com/CyanVoxel) +##### #743 by @CyanVoxel Added "Smartcase" and Globless Path Search -- `path: temp`: Returns all paths that have "temp" **(Case insensitive)** somewhere in the name. -- `path: Temp`: Returns all paths that have "Temp" **(Case sensitive)** somewhere in the name. +- `path: temp`: Returns all paths that have "temp" **(Case insensitive)** somewhere in the name. +- `path: Temp`: Returns all paths that have "Temp" **(Case sensitive)** somewhere in the name. Glob Patterns w/ Smartcase -- `path: *temp*`: Returns all paths that have "temp" **(Case insensitive)** somewhere in the name. -- `path: *Temp*`: Returns all paths that have "Temp" **(Case sensitive)** somewhere in the name. -- `path: temp*`: Returns all paths that start with "temp" **(Case insensitive)** somewhere in the name. -- `path: Temp*`: Returns all paths that start with "Temp" **(Case sensitive)** somewhere in the name. -- `path: *temp`: Returns all paths that end with "temp" **(Case insensitive)** somewhere in the name. -- `path: *TEmP`: Returns all paths that end with "TEmP" **(Case sensitive)** somewhere in the name. +- `path: *temp*`: Returns all paths that have "temp" **(Case insensitive)** somewhere in the name. +- `path: *Temp*`: Returns all paths that have "Temp" **(Case sensitive)** somewhere in the name. +- `path: temp*`: Returns all paths that start with "temp" **(Case insensitive)** somewhere in the name. +- `path: Temp*`: Returns all paths that start with "Temp" **(Case sensitive)** somewhere in the name. +- `path: *temp`: Returns all paths that end with "temp" **(Case insensitive)** somewhere in the name. +- `path: *TEmP`: Returns all paths that end with "TEmP" **(Case sensitive)** somewhere in the name. -##### [#788](https://github.com/TagStudioDev/TagStudio/pull/788) by [@CyanVoxel](https://github.com/CyanVoxel) +##### #788 by @CyanVoxel -- Added a "View Limit" dropdown to tag search boxes to limit the number of on-screen tags. Previously this limit was hardcoded to 100, but now options range from 25 to unlimited. - +- Added a "View Limit" dropdown to tag search boxes to limit the number of on-screen tags. Previously this limit was hardcoded to 100, but now options range from 25 to unlimited. + ### Changed -- fix(ui): expand usage of esc and enter for modals by [@CyanVoxel](https://github.com/CyanVoxel) in [#793](https://github.com/TagStudioDev/TagStudio/pull/793) -- perf: optimize query methods and reduce preview panel updates by [@CyanVoxel](https://github.com/CyanVoxel) in [#794](https://github.com/TagStudioDev/TagStudio/pull/794) +- fix(ui): expand usage of esc and enter for modals by @CyanVoxel in #793 +- perf: optimize query methods and reduce preview panel updates by @CyanVoxel in #794 -##### [#788](https://github.com/TagStudioDev/TagStudio/pull/788) by [@CyanVoxel](https://github.com/CyanVoxel) +##### #788 by @CyanVoxel -- Improved performance of tag search boxes, including the tag manager +- Improved performance of tag search boxes, including the tag manager ### Fixed -- fix(ui): hide library actions when no library is open by [@CyanVoxel](https://github.com/CyanVoxel) in [#787](https://github.com/TagStudioDev/TagStudio/pull/787) -- feat: port file trashing ([#409](https://github.com/TagStudioDev/TagStudio/pull/409)) to v9.5 by [@CyanVoxel](https://github.com/CyanVoxel) in [#792](https://github.com/TagStudioDev/TagStudio/pull/792) +- fix(ui): hide library actions when no library is open by @CyanVoxel in #787 +- feat: port file trashing (#409) to v9.5 by @CyanVoxel in #792 ### Docs -- Added references to alternative POSIX shells, as well as pyenv to CONTRIBUTING.md by [@ChloeZamorano](https://github.com/ChloeZamorano) in [#791](https://github.com/TagStudioDev/TagStudio/pull/791) +- Added references to alternative POSIX shells, as well as pyenv to CONTRIBUTING.md by @ChloeZamorano in #791 --- -## 9.5.0-pr2 [February 3rd, 2025] +## 9.5.0-pr2 February 3rd, 2025 ### Added -##### [#784](https://github.com/TagStudioDev/TagStudio/pull/784) by [@CyanVoxel](https://github.com/CyanVoxel) +##### #784 by @CyanVoxel -- Add Ctrl+M shortcut to open the "Tag Manager" +- Add Ctrl+M shortcut to open the "Tag Manager" ### Fixed -- fix: don't wrap field names too early by [@CyanVoxel](https://github.com/CyanVoxel) in [2215403](https://github.com/TagStudioDev/TagStudio/commit/2215403201e3b416a43ead0a322688180af6d71b) and [90a826d](https://github.com/TagStudioDev/TagStudio/commit/90a826d12804b3386a0b9003abb20f23f88ab3be) -- fix: save all tag attributes from "Create & Add" modal by [@SkeleyM](https://github.com/SkeleyM) in [#762](https://github.com/TagStudioDev/TagStudio/pull/762) -- fix: allow tag names with colons in search by [@SkeleyM](https://github.com/SkeleyM) in [#765](https://github.com/TagStudioDev/TagStudio/pull/765) -- fix: catch `ParsingError` by [@CyanVoxel](https://github.com/CyanVoxel) in [#779](https://github.com/TagStudioDev/TagStudio/pull/779) -- fix: patch incorrect description type & invalid disambiguation_id refs by [@CyanVoxel](https://github.com/CyanVoxel) in [#782](https://github.com/TagStudioDev/TagStudio/pull/782) +- fix: don't wrap field names too early by @CyanVoxel in 2215403201e3b416a43ead0a322688180af6d71b and 90a826d12804b3386a0b9003abb20f23f88ab3be +- fix: save all tag attributes from "Create & Add" modal by @SkeleyM in #762 +- fix: allow tag names with colons in search by @SkeleyM in #765 +- fix: catch `ParsingError` by @CyanVoxel in #779 +- fix: patch incorrect description type & invalid disambiguation_id refs by @CyanVoxel in #782 -##### [#784](https://github.com/TagStudioDev/TagStudio/pull/784) by [@CyanVoxel](https://github.com/CyanVoxel) +##### #784 by @CyanVoxel -- Reset tag search box and focus each time a tag search panel is opened -- Include tag parents in tag search results (v9.4 parity) -- Lowercase tag names now get properly sorted with uppercase ones -- Don't include tag display names in "closeness" factor when searching -- Escape "&" characters inside tag names so Qt doesn't treat them as mnemonics -- Set minimum tag width -- Fix "Add Tags" panel missing its window title when accessing from the keyboard shortcut +- Reset tag search box and focus each time a tag search panel is opened +- Include tag parents in tag search results (v9.4 parity) +- Lowercase tag names now get properly sorted with uppercase ones +- Don't include tag display names in "closeness" factor when searching +- Escape "&" characters inside tag names so Qt doesn't treat them as mnemonics +- Set minimum tag width +- Fix "Add Tags" panel missing its window title when accessing from the keyboard shortcut ### Changed -##### [#784](https://github.com/TagStudioDev/TagStudio/pull/784) by [@CyanVoxel](https://github.com/CyanVoxel) +##### #784 by @CyanVoxel -- The "use for disambiguation" button has been moved to the right-hand side of parent tags in order to prevent accidental clicks involving the left-hand "remove tag" button -- Add "Create & Add" button to the bottom of all non-whitespace searches, even if they return some tags -- The awkward "+" button next to tags in the "Add Tags" panel has been removed in favor of clicking on tags themselves -- Improved visual feedback for highlighting, keyboard focusing, and clicking tags -- The clickable area of the "-" button on tags has been increased and has visual feedback when you hover and click it -- You can now tab into the tag search list and add tags with a spacebar press (previously possible but very janky) -- In tag search panels, pressing the Esc key will return your focus to the search bar and highlight your previous query. If the search box is already highlighted, pressing Esc will close the modal -- In modals such as the "Add Tag" and "Edit Tag" panels, pressing Esc will cancel the operation and close the modal +- The "use for disambiguation" button has been moved to the right-hand side of parent tags in order to prevent accidental clicks involving the left-hand "remove tag" button +- Add "Create & Add" button to the bottom of all non-whitespace searches, even if they return some tags +- The awkward "+" button next to tags in the "Add Tags" panel has been removed in favor of clicking on tags themselves +- Improved visual feedback for highlighting, keyboard focusing, and clicking tags +- The clickable area of the "-" button on tags has been increased and has visual feedback when you hover and click it +- You can now tab into the tag search list and add tags with a spacebar press (previously possible but very janky) +- In tag search panels, pressing the Esc key will return your focus to the search bar and highlight your previous query. If the search box is already highlighted, pressing Esc will close the modal +- In modals such as the "Add Tag" and "Edit Tag" panels, pressing Esc will cancel the operation and close the modal ### Internal Changes -- refactor: wrap migration_iterator lambda in a try/except block by [@CyanVoxel](https://github.com/CyanVoxel) in [#773](https://github.com/TagStudioDev/TagStudio/pull/773) +- refactor: wrap migration_iterator lambda in a try/except block by @CyanVoxel in #773 ### Docs -- docs: update field and library pages by [@CyanVoxel](https://github.com/CyanVoxel) in [f5ff4d7](https://github.com/TagStudioDev/TagStudio/commit/f5ff4d78c1ad53134e9c64698886aee68c0f1dc1) -- docs: add information about "tag manager" by [@CyanVoxel](https://github.com/CyanVoxel) in [9bdbafa](https://github.com/TagStudioDev/TagStudio/commit/9bdbafa40c4274922f6533b5b5fcee9a4fe43030) -- docs: add note about glob searching in the readme by [@CyanVoxel](https://github.com/CyanVoxel) in [6e402ac](https://github.com/TagStudioDev/TagStudio/commit/6e402ac34d2d60e71fbd36ad234fe3914d5eb8e0) -- docs: add library_search page by [@CyanVoxel](https://github.com/CyanVoxel) in [5be7dfc](https://github.com/TagStudioDev/TagStudio/commit/5be7dfc314b21042c18b2f08893f2b452d12394a) -- docs: docs: add more links to index.md by [@CyanVoxel](https://github.com/CyanVoxel) in [d795889](https://github.com/TagStudioDev/TagStudio/commit/d7958892b7762586837204d686a6a2a993e3c26e) -- docs: fix typo for "category" in usage.md by [@pinheadtf2](https://github.com/pinheadtf2) in [#760](https://github.com/TagStudioDev/TagStudio/pull/760) -- fix(docs): fix screenshot sometimes not rendering by [@SkeleyM](https://github.com/SkeleyM) in [#775](https://github.com/TagStudioDev/TagStudio/pull/775) +- docs: update field and library pages by @CyanVoxel in f5ff4d78c1ad53134e9c64698886aee68c0f1dc1 +- docs: add information about "tag manager" by @CyanVoxel in 9bdbafa40c4274922f6533b5b5fcee9a4fe43030 +- docs: add note about glob searching in the readme by @CyanVoxel in 6e402ac34d2d60e71fbd36ad234fe3914d5eb8e0 +- docs: add library_search page by @CyanVoxel in 5be7dfc314b21042c18b2f08893f2b452d12394a +- docs: docs: add more links to index.md by @CyanVoxel in d7958892b7762586837204d686a6a2a993e3c26e +- docs: fix typo for "category" in usage.md by @pinheadtf2 in #760 +- fix(docs): fix screenshot sometimes not rendering by @SkeleyM in #775 --- -## 9.5.0-pr1 [January 31st, 2025] +## 9.5.0-pr1 January 31st, 2025 ### Added @@ -698,88 +805,88 @@ Glob Patterns w/ Smartcase ##### Boolean Operators -- feat: implement query language by [@Computerdores](https://github.com/Computerdores) in [#606](https://github.com/TagStudioDev/TagStudio/pull/606) -- feat: optimize AND queries by [@Computerdores](https://github.com/Computerdores) in [#679](https://github.com/TagStudioDev/TagStudio/pull/679) +- feat: implement query language by @Computerdores in #606 +- feat: optimize AND queries by @Computerdores in #679 ##### Filetype, Mediatype, and Glob Path Searches -- fix: remove wildcard requirement for tags by [@Tyrannicodin](https://github.com/Tyrannicodin) in [#481](https://github.com/TagStudioDev/TagStudio/pull/481) -- feat: add filetype and mediatype searches by [@python357-1](https://github.com/python357-1) in [#575](https://github.com/TagStudioDev/TagStudio/pull/575) -- feat: make path search use globs by [@python357-1](https://github.com/python357-1) in [#582](https://github.com/TagStudioDev/TagStudio/pull/582) -- feat: implement search equivalence of "jpg" and "jpeg" filetypes by [@Computerdores](https://github.com/Computerdores) in [#649](https://github.com/TagStudioDev/TagStudio/pull/649) +- fix: remove wildcard requirement for tags by @Tyrannicodin in #481 +- feat: add filetype and mediatype searches by @python357-1 in #575 +- feat: make path search use globs by @python357-1 in #582 +- feat: implement search equivalence of "jpg" and "jpeg" filetypes by @Computerdores in #649 ##### Sortable Results -- feat: sort by "date added" in library by [@Computerdores](https://github.com/Computerdores) in [#674](https://github.com/TagStudioDev/TagStudio/pull/674) +- feat: sort by "date added" in library by @Computerdores in #674 ##### Autocomplete -- feat: add autocomplete for search engine by [@python357-1](https://github.com/python357-1) in [#586](https://github.com/TagStudioDev/TagStudio/pull/586) +- feat: add autocomplete for search engine by @python357-1 in #586 #### Replaced "Tag Fields" with Tag Categories Instead of tags needing to be added to a tag field type such as "Meta Tags", "Content Tags", or just the "Tags" field, tags are now added directly to file entries with no intermediary step. While tag field types offered a way to further organize tags, it was cumbersome, inflexible, and simply not fully fleshed out. Tag Categories offer all of the previous (intentional) functionality while greatly increasing the ease of use and customization. -- feat!: tag categories by [@CyanVoxel](https://github.com/CyanVoxel) in [#655](https://github.com/TagStudioDev/TagStudio/pull/655) +- feat!: tag categories by @CyanVoxel in #655 #### Thumbnails and File Previews ##### New Thumbnail Support -- feat: add svg thumbnail support (port [#442](https://github.com/TagStudioDev/TagStudio/pull/442)) by [@Tyrannicodin](https://github.com/Tyrannicodin) and [@CyanVoxel](https://github.com/CyanVoxel) in [#540](https://github.com/TagStudioDev/TagStudio/pull/540) -- feat: add pdf thumbnail support (port [#378](https://github.com/TagStudioDev/TagStudio/pull/378)) by [@Heiholf](https://github.com/Heiholf) and [@CyanVoxel](https://github.com/CyanVoxel) in [#543](https://github.com/TagStudioDev/TagStudio/pull/543) -- feat: add ePub thumbnail support (port [#387](https://github.com/TagStudioDev/TagStudio/pull/387)) by [@Heiholf](https://github.com/Heiholf) and [@CyanVoxel](https://github.com/CyanVoxel) in [#539](https://github.com/TagStudioDev/TagStudio/pull/539) -- feat: add OpenDocument thumbnail support (port [#366](https://github.com/TagStudioDev/TagStudio/pull/366)) by [@Joshua-Beatty](https://github.com/Joshua-Beatty) and [@CyanVoxel](https://github.com/CyanVoxel) in [#545](https://github.com/TagStudioDev/TagStudio/pull/545) -- feat: add JXL thumbnail and animated APNG + WEBP support (port [#344](https://github.com/TagStudioDev/TagStudio/pull/344) and partially port [#357](https://github.com/TagStudioDev/TagStudio/pull/357)) by [@BPplays](https://github.com/BPplays) and [@CyanVoxel](https://github.com/CyanVoxel) in [#549](https://github.com/TagStudioDev/TagStudio/pull/549) - - fix: catch ImportError for pillow_jxl module by [@CyanVoxel](https://github.com/CyanVoxel) in [a2f9685](https://github.com/TagStudioDev/TagStudio/commit/a2f9685bc0d744ea6f5334c6d2926aad3f6d375a) +- feat: add svg thumbnail support (port #442) by @Tyrannicodin and @CyanVoxel in #540 +- feat: add pdf thumbnail support (port #378) by @Heiholf and @CyanVoxel in #543 +- feat: add ePub thumbnail support (port #387) by @Heiholf and @CyanVoxel in #539 +- feat: add OpenDocument thumbnail support (port #366) by @Joshua-Beatty and @CyanVoxel in #545 +- feat: add JXL thumbnail and animated APNG + WEBP support (port #344 and partially port #357) by @BPplays and @CyanVoxel in #549 + - fix: catch ImportError for pillow_jxl module by @CyanVoxel in a2f9685bc0d744ea6f5334c6d2926aad3f6d375a ##### Audio Playback -- feat: audio playback by [@csponge](https://github.com/csponge) in [#576](https://github.com/TagStudioDev/TagStudio/pull/576) - - feat(ui): add audio volume slider by [@SkeleyM](https://github.com/SkeleyM) in [#691](https://github.com/TagStudioDev/TagStudio/pull/691) +- feat: audio playback by @csponge in #576 + - feat(ui): add audio volume slider by @SkeleyM in #691 ##### Thumbnail Caching -- feat(ui): add thumbnail caching by [@CyanVoxel](https://github.com/CyanVoxel) in [#694](https://github.com/TagStudioDev/TagStudio/pull/694) +- feat(ui): add thumbnail caching by @CyanVoxel in #694 #### Tags ##### Delete Tags _(Finally!)_ -- feat: remove and create tags from tag database panel by [@DandyDev01](https://github.com/DandyDev01) in [#569](https://github.com/TagStudioDev/TagStudio/pull/569) +- feat: remove and create tags from tag database panel by @DandyDev01 in #569 ##### New Tag Colors + UI -- feat: expanded tag color system by [@CyanVoxel](https://github.com/CyanVoxel) in [#709](https://github.com/TagStudioDev/TagStudio/pull/709) -- fix(ui): use correct pink tag color by [@CyanVoxel](https://github.com/CyanVoxel) in [431efe4](https://github.com/TagStudioDev/TagStudio/commit/431efe4fe93213141c763e59ca9887215766fd42) -- fix(ui): use consistent tag outline colors by [@CyanVoxel](https://github.com/CyanVoxel) in [020a73d](https://github.com/TagStudioDev/TagStudio/commit/020a73d095c74283d6c80426d3c3db8874409952) +- feat: expanded tag color system by @CyanVoxel in #709 +- fix(ui): use correct pink tag color by @CyanVoxel in 431efe4fe93213141c763e59ca9887215766fd42 +- fix(ui): use consistent tag outline colors by @CyanVoxel in 020a73d095c74283d6c80426d3c3db8874409952 ##### New Tag Alias UI -- fix: preview panel aliases not staying up to date with database by [@DandyDev01](https://github.com/DandyDev01) in [#641](https://github.com/TagStudioDev/TagStudio/pull/641) -- fix: subtags/parent tags & aliases update the UI for building a tag by [@DandyDev01](https://github.com/DandyDev01) in [#534](https://github.com/TagStudioDev/TagStudio/pull/534) +- fix: preview panel aliases not staying up to date with database by @DandyDev01 in #641 +- fix: subtags/parent tags & aliases update the UI for building a tag by @DandyDev01 in #534 #### Miscellaneous -- feat: about section by [@mashed5894](https://github.com/mashed5894) in [#712](https://github.com/TagStudioDev/TagStudio/pull/712) -- feat(ui): add configurable splash screens by [@CyanVoxel](https://github.com/CyanVoxel) in [#703](https://github.com/TagStudioDev/TagStudio/pull/703) -- feat(ui): show filenames in thumbnail grid by [@CyanVoxel](https://github.com/CyanVoxel) in [#633](https://github.com/TagStudioDev/TagStudio/pull/633) +- feat: about section by @mashed5894 in #712 +- feat(ui): add configurable splash screens by @CyanVoxel in #703 +- feat(ui): show filenames in thumbnail grid by @CyanVoxel in #633 ### Fixed -- fix(ui): display all tags in panel during empty search by [@samuellieberman](https://github.com/samuellieberman) in [#328](https://github.com/TagStudioDev/TagStudio/pull/328) -- fix: avoid `KeyError` in `add_folders_to_tree()` (fix [#346](https://github.com/TagStudioDev/TagStudio/issues/346)) by [@CyanVoxel](https://github.com/CyanVoxel) in [#347](https://github.com/TagStudioDev/TagStudio/pull/347) -- fix: error on closing library by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#484](https://github.com/TagStudioDev/TagStudio/pull/484) -- fix: resolution info [#550](https://github.com/TagStudioDev/TagStudio/issues/550) by [@Roc25](https://github.com/Roc25) in [#551](https://github.com/TagStudioDev/TagStudio/pull/551) -- fix: remove queued thumnail jobs when closing library by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#583](https://github.com/TagStudioDev/TagStudio/pull/583) -- fix: use absolute ffprobe path on macos (Fix [#511](https://github.com/TagStudioDev/TagStudio/issues/511)) by [@CyanVoxel](https://github.com/CyanVoxel) in [#629](https://github.com/TagStudioDev/TagStudio/pull/629) -- fix(ui): prevent duplicate parent tags in UI by [@SkeleyM](https://github.com/SkeleyM) in [#665](https://github.com/TagStudioDev/TagStudio/pull/665) -- fix: fix -o flag not working if path has whitespace around it by [@python357-1](https://github.com/python357-1) in [#670](https://github.com/TagStudioDev/TagStudio/pull/670) -- fix: better file opening compatibility with non-ascii filenames by [@SkeleyM](https://github.com/SkeleyM) in [#667](https://github.com/TagStudioDev/TagStudio/pull/667) -- fix: restore environment before launching external programs by [@mashed5894](https://github.com/mashed5894) in [#707](https://github.com/TagStudioDev/TagStudio/pull/707) -- fix: have pydub use known ffmpeg + ffprobe locations by [@CyanVoxel](https://github.com/CyanVoxel) in [#724](https://github.com/TagStudioDev/TagStudio/pull/724) -- fix: add ".DS_Store" to `GLOBAL_IGNORE_SET` by [@CyanVoxel](https://github.com/CyanVoxel) in [b72a2f2](https://github.com/TagStudioDev/TagStudio/commit/b72a2f233141db4db6aa6be8796b626ebd3f0756) -- fix: don't add ".\_" files to libraries by [@CyanVoxel](https://github.com/CyanVoxel) in [eb1f634](https://github.com/TagStudioDev/TagStudio/commit/eb1f634d386cd8a5ecee1e6ff6a0b7d8811550fa) +- fix(ui): display all tags in panel during empty search by @samuellieberman in #328 +- fix: avoid `KeyError` in `add_folders_to_tree()` (fix #346) by @CyanVoxel in #347 +- fix: error on closing library by @yedpodtrzitko in #484 +- fix: resolution info #550 by @Roc25 in #551 +- fix: remove queued thumnail jobs when closing library by @yedpodtrzitko in #583 +- fix: use absolute ffprobe path on macos (fix #511) by @CyanVoxel in #629 +- fix(ui): prevent duplicate parent tags in UI by @SkeleyM in #665 +- fix: fix -o flag not working if path has whitespace around it by @python357-1 in #670 +- fix: better file opening compatibility with non-ascii filenames by @SkeleyM in #667 +- fix: restore environment before launching external programs by @mashed5894 in #707 +- fix: have pydub use known ffmpeg + ffprobe locations by @CyanVoxel in #724 +- fix: add ".DS_Store" to `GLOBAL_IGNORE_SET` by @CyanVoxel in b72a2f233141db4db6aa6be8796b626ebd3f0756 +- fix: don't add ".\_" files to libraries by @CyanVoxel in eb1f634d386cd8a5ecee1e6ff6a0b7d8811550fa ### Changed @@ -787,276 +894,276 @@ Instead of tags needing to be added to a tag field type such as "Meta Tags", "Co This was the main focus of this update, and where the majority of development time and resources have been spent since v9.4. These changes include everything that was done to migrate from the JSON format to SQLite starting from the initial SQLite PR, while re-implementing every feature from v9.4 as the initial SQLite PR was based on v9.3.x at the time. -- refactor!: use SQLite and SQLAlchemy for database backend by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#332](https://github.com/TagStudioDev/TagStudio/pull/332) -- feat: make search results more ergonomic by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#498](https://github.com/TagStudioDev/TagStudio/pull/498) -- feat: store `Entry` suffix separately by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#503](https://github.com/TagStudioDev/TagStudio/pull/503) -- feat: port thumbnail ([#390](https://github.com/TagStudioDev/TagStudio/pull/390)) and related features to v9.5 by [@CyanVoxel](https://github.com/CyanVoxel) in [#522](https://github.com/TagStudioDev/TagStudio/pull/522) -- fix: don't check db version with new library by [@yedpodtrzitko](https://github.com/yedpodtrzitko) in [#536](https://github.com/TagStudioDev/TagStudio/pull/536) -- fix(ui): update ui when removing fields by [@DandyDev01](https://github.com/DandyDev01) in [#560](https://github.com/TagStudioDev/TagStudio/pull/560) -- feat(parity): backend for aliases and parent tags by [@DandyDev01](https://github.com/DandyDev01) in [#596](https://github.com/TagStudioDev/TagStudio/pull/596) -- fix: "open in explorer" opens correct folder by [@KirilBourakov](https://github.com/KirilBourakov) in [#603](https://github.com/TagStudioDev/TagStudio/pull/603) -- fix: ui/ux parity fixes for thumbnails and files by [@CyanVoxel](https://github.com/CyanVoxel) in [#608](https://github.com/TagStudioDev/TagStudio/pull/608) -- feat(parity): migrate json libraries to sqlite by [@CyanVoxel](https://github.com/CyanVoxel) in [#604](https://github.com/TagStudioDev/TagStudio/pull/604) -- fix: clear all setting values when opening a library by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) in [#622](https://github.com/TagStudioDev/TagStudio/pull/622) -- fix: remove/rework windows path tests by [@VasigaranAndAngel](https://github.com/VasigaranAndAngel) in [#625](https://github.com/TagStudioDev/TagStudio/pull/625) -- fix: add check to see if library is loaded in filter_items by [@Roc25](https://github.com/Roc25) in [#547](https://github.com/TagStudioDev/TagStudio/pull/547) -- fix: multiple macro errors by [@Computerdores](https://github.com/Computerdores) in [#612](https://github.com/TagStudioDev/TagStudio/pull/612) -- fix: don't allow blank tag alias values in db by [@CyanVoxel](https://github.com/CyanVoxel) in [#628](https://github.com/TagStudioDev/TagStudio/pull/628) -- feat: Reimplement drag drop files on sql migration by [@seakrueger](https://github.com/seakrueger) in [#528](https://github.com/TagStudioDev/TagStudio/pull/528) -- fix: stop sqlite db from being updated while running tests by [@python357-1](https://github.com/python357-1) in [#648](https://github.com/TagStudioDev/TagStudio/pull/648) -- fix: enter/return adds top result tag by [@SkeleyM](https://github.com/SkeleyM) in [#651](https://github.com/TagStudioDev/TagStudio/pull/651) -- fix: show correct unlinked files count by [@SkeleyM](https://github.com/SkeleyM) in [#653](https://github.com/TagStudioDev/TagStudio/pull/653) -- feat: implement parent tag search by [@Computerdores](https://github.com/Computerdores) in [#673](https://github.com/TagStudioDev/TagStudio/pull/673) -- fix: only close add tag menu with no search by [@SkeleyM](https://github.com/SkeleyM) in [#685](https://github.com/TagStudioDev/TagStudio/pull/685) -- fix: drag and drop no longer resets by [@SkeleyM](https://github.com/SkeleyM) in [#710](https://github.com/TagStudioDev/TagStudio/pull/710) -- feat(ui): port "create and add tag" to main branch by [@SkeleyM](https://github.com/SkeleyM) in [#711](https://github.com/TagStudioDev/TagStudio/pull/711) -- fix: don't add default title field, use proper phrasing for adding files by [@CyanVoxel](https://github.com/CyanVoxel) in [#701](https://github.com/TagStudioDev/TagStudio/pull/701) -- fix: preview panel + main window fixes and optimizations by [@CyanVoxel](https://github.com/CyanVoxel) in [#700](https://github.com/TagStudioDev/TagStudio/pull/700) -- fix: sort tag results by [@mashed5894](https://github.com/mashed5894) in [#721](https://github.com/TagStudioDev/TagStudio/pull/721) -- fix: restore opening last library on startup by [@SkeleyM](https://github.com/SkeleyM) in [#729](https://github.com/TagStudioDev/TagStudio/pull/729) -- fix(ui): don't always create tag on enter by [@SkeleyM](https://github.com/SkeleyM) in [#731](https://github.com/TagStudioDev/TagStudio/pull/731) -- fix: use tag aliases in tag search by [@CyanVoxel](https://github.com/CyanVoxel) in [#726](https://github.com/TagStudioDev/TagStudio/pull/726) -- fix: keep initial id order in `get_entries_full()` by [@CyanVoxel](https://github.com/CyanVoxel) in [#736](https://github.com/TagStudioDev/TagStudio/pull/736) -- fix: always catch db mismatch by [@CyanVoxel](https://github.com/CyanVoxel) in [#738](https://github.com/TagStudioDev/TagStudio/pull/738) -- fix: relink unlinked entry to existing entry without sql error by [@mashed5894](https://github.com/mashed5894) in [#730](https://github.com/TagStudioDev/TagStudio/issues/730) -- fix: refactor and fix bugs with missing_files.py by [@CyanVoxel](https://github.com/CyanVoxel) in [#739](https://github.com/TagStudioDev/TagStudio/pull/739) -- fix: dragging files references correct entry IDs [@CyanVoxel](https://github.com/CyanVoxel) in [44ff17c](https://github.com/TagStudioDev/TagStudio/commit/44ff17c0b3f05570e356c112f005dbc14c7cc05d) -- ui: port splash screen from Alpha-v9.4 by [@CyanVoxel](https://github.com/CyanVoxel) in [af760ee](https://github.com/TagStudioDev/TagStudio/commit/af760ee61a523c84bab0fb03a68d7465866d0e05) -- fix: tags created from tag database now add aliases by [@CyanVoxel](https://github.com/CyanVoxel) in [2903dd2](https://github.com/TagStudioDev/TagStudio/commit/2903dd22c45c02498687073d075bb88886de6b62) -- fix: check for tag name parity during JSON migration by [@CyanVoxel](https://github.com/CyanVoxel) in [#748](https://github.com/TagStudioDev/TagStudio/pull/748) -- feat(ui): re-implement tag display names on sql by [@CyanVoxel](https://github.com/CyanVoxel) in [#747](https://github.com/TagStudioDev/TagStudio/pull/747) -- fix(ui): restore Windows accent color on PySide 6.8.0.1 by [@CyanVoxel](https://github.com/CyanVoxel) in [#755](https://github.com/TagStudioDev/TagStudio/pull/755) -- fix(ui): (mostly) fix right-click search option on tags by [@CyanVoxel](https://github.com/CyanVoxel) in [#756](https://github.com/TagStudioDev/TagStudio/pull/756) -- feat: copy/paste fields and tags by [@mashed5894](https://github.com/mashed5894) in [#722](https://github.com/TagStudioDev/TagStudio/pull/722) +- refactor!: use SQLite and SQLAlchemy for database backend by @yedpodtrzitko in #332 +- feat: make search results more ergonomic by @yedpodtrzitko in #498 +- feat: store `Entry` suffix separately by @yedpodtrzitko in #503 +- feat: port thumbnail (#390) and related features to v9.5 by @CyanVoxel in #522 +- fix: don't check db version with new library by @yedpodtrzitko in #536 +- fix(ui): update ui when removing fields by @DandyDev01 in #560 +- feat(parity): backend for aliases and parent tags by @DandyDev01 in #596 +- fix: "open in explorer" opens correct folder by @KirilBourakov in #603 +- fix: ui/ux parity fixes for thumbnails and files by @CyanVoxel in #608 +- feat(parity): migrate json libraries to sqlite by @CyanVoxel in #604 +- fix: clear all setting values when opening a library by @VasigaranAndAngel in #622 +- fix: remove/rework windows path tests by @VasigaranAndAngel in #625 +- fix: add check to see if library is loaded in filter_items by @Roc25 in #547 +- fix: multiple macro errors by @Computerdores in #612 +- fix: don't allow blank tag alias values in db by @CyanVoxel in #628 +- feat: Reimplement drag drop files on sql migration by @seakrueger in #528 +- fix: stop sqlite db from being updated while running tests by @python357-1 in #648 +- fix: enter/return adds top result tag by @SkeleyM in #651 +- fix: show correct unlinked files count by @SkeleyM in #653 +- feat: implement parent tag search by @Computerdores in #673 +- fix: only close add tag menu with no search by @SkeleyM in #685 +- fix: drag and drop no longer resets by @SkeleyM in #710 +- feat(ui): port "create and add tag" to main branch by @SkeleyM in #711 +- fix: don't add default title field, use proper phrasing for adding files by @CyanVoxel in #701 +- fix: preview panel + main window fixes and optimizations by @CyanVoxel in #700 +- fix: sort tag results by @mashed5894 in #721 +- fix: restore opening last library on startup by @SkeleyM in #729 +- fix(ui): don't always create tag on enter by @SkeleyM in #731 +- fix: use tag aliases in tag search by @CyanVoxel in #726 +- fix: keep initial id order in `get_entries_full()` by @CyanVoxel in #736 +- fix: always catch db mismatch by @CyanVoxel in #738 +- fix: relink unlinked entry to existing entry without sql error by @mashed5894 in #730 +- fix: refactor and fix bugs with missing_files.py by @CyanVoxel in #739 +- fix: dragging files references correct entry IDs @CyanVoxel in 44ff17c0b3f05570e356c112f005dbc14c7cc05d +- ui: port splash screen from Alpha-v9.4 by @CyanVoxel in af760ee61a523c84bab0fb03a68d7465866d0e05 +- fix: tags created from tag database now add aliases by @CyanVoxel in 2903dd22c45c02498687073d075bb88886de6b62 +- fix: check for tag name parity during JSON migration by @CyanVoxel in #748 +- feat(ui): re-implement tag display names on sql by @CyanVoxel in #747 +- fix(ui): restore Windows accent color on PySide 6.8.0.1 by @CyanVoxel in #755 +- fix(ui): (mostly) fix right-click search option on tags by @CyanVoxel in #756 +- feat: copy/paste fields and tags by @mashed5894 in #722 #### UI/UX -- feat(ui): pre-select default tag name in `BuildTagPanel` by [@Cool-Game-Dev](https://github.com/Cool-Game-Dev) in [#592](https://github.com/TagStudioDev/TagStudio/pull/592) -- feat(ui): keyboard navigation for editing tags by [@Computerdores](https://github.com/Computerdores) in [#407](https://github.com/TagStudioDev/TagStudio/pull/407) -- feat(ui): use tag query as default new tag name by [@CyanVoxel](https://github.com/CyanVoxel) in [29c0dfd](https://github.com/TagStudioDev/TagStudio/commit/29c0dfdb2d88e8f473e27c7f1fe7ede6e5bd0feb) -- feat(ui): shortcut to add tags to selected entries; change click behavior of tags to edit by [@CyanVoxel](https://github.com/CyanVoxel) in [#749](https://github.com/TagStudioDev/TagStudio/pull/749) -- fix(ui): use consistent dark mode colors for all systems by [@CyanVoxel](https://github.com/CyanVoxel) in [#752](https://github.com/TagStudioDev/TagStudio/pull/752) -- fix(ui): use camera white balance for raw images by [@CyanVoxel](https://github.com/CyanVoxel) in [6ee5304](https://github.com/TagStudioDev/TagStudio/commit/6ee5304b52f217af0f5df543fcb389649203d6b2) -- Mixed field editing has been limited due to various bugs in both the JSON and SQL implementations. This will be re-implemented in a future release. +- feat(ui): pre-select default tag name in `BuildTagPanel` by @Cool-Game-Dev in #592 +- feat(ui): keyboard navigation for editing tags by @Computerdores in #407 +- feat(ui): use tag query as default new tag name by @CyanVoxel in 29c0dfdb2d88e8f473e27c7f1fe7ede6e5bd0feb +- feat(ui): shortcut to add tags to selected entries; change click behavior of tags to edit by @CyanVoxel in #749 +- fix(ui): use consistent dark mode colors for all systems by @CyanVoxel in #752 +- fix(ui): use camera white balance for raw images by @CyanVoxel in 6ee5304b52f217af0f5df543fcb389649203d6b2 +- Mixed field editing has been limited due to various bugs in both the JSON and SQL implementations. This will be re-implemented in a future release. #### Performance -- feat: improve performance of "Delete Missing Entries" by [@Toby222](https://github.com/Toby222) and [@Computerdores](https://github.com/Computerdores) in [#696](https://github.com/TagStudioDev/TagStudio/pull/696) +- feat: improve performance of "Delete Missing Entries" by @Toby222 and @Computerdores in #696 #### Internal Changes -- refactor: combine open launch args by [@UnusualEgg](https://github.com/UnusualEgg) in [#364](https://github.com/TagStudioDev/TagStudio/pull/364) -- feat: add date_created, date_modified, and date_added columns to entries table by [@CyanVoxel](https://github.com/CyanVoxel) in [#740](https://github.com/TagStudioDev/TagStudio/pull/740) +- refactor: combine open launch args by @UnusualEgg in #364 +- feat: add date_created, date_modified, and date_added columns to entries table by @CyanVoxel in #740 --- -## 9.4.2 [December 1st, 2024] +## 9.4.2 December 1st, 2024 ### Added/Fixed -- Create auto-backup of library for use in save failures (Fix [#343](https://github.com/TagStudioDev/TagStudio/issues/343)) by [@CyanVoxel](https://github.com/CyanVoxel) in [#554](https://github.com/TagStudioDev/TagStudio/pull/554) +- Create auto-backup of library for use in save failures (fix #343) by @CyanVoxel in #554 --- -## 9.4.1 [September 13th, 2024] +## 9.4.1 September 13th, 2024 ### Added -- Warn user if FFmpeg is not installed -- Support for `.raf` and `.orf` raw image thumbnails and previews +- Warn user if FFmpeg is not installed +- Support for `.raf` and `.orf` raw image thumbnails and previews ### Fixed -- Use `birthtime` for file creation time on Mac & Windows -- Use audio icon fallback when FFmpeg is not detected -- Retain search query upon directory refresh +- Use `birthtime` for file creation time on Mac & Windows +- Use audio icon fallback when FFmpeg is not detected +- Retain search query upon directory refresh ### Changed -- Significantly improve file re-scanning performance +- Significantly improve file re-scanning performance --- -## 9.4.0 [September 3rd, 2024] +## 9.4.0 September 3rd, 2024 ### Added -- Copy and paste fields -- Add multiple fields at once -- Drag and drop files in/out of the program - - Files can be shared by dragging them from the thumbnail grid to other programs - - Files can be added to library folder by dragging them into the program -- Manage Python virtual environment in Nix flake -- Ability to create tag when adding tags -- Blender preview thumbnail support -- File deletion/trashing - - Added right-click option on thumbnails and preview panel to delete files - - Added Edit Menu option for deleting files - - Added Delete key shortcut for deleting files -- Font preview thumbnail support - - Short "Aa" previews for thumbnails - - Full alphabet preview for the preview pane -- Sort tags by alphabetical/color -- File explorer action follows OS naming -- Preview Source Engine files -- Expanded thumbnail and preview features - - Add album cover art thumbnails - - Add audio waveform thumbnails for audio files without embedded cover art - - Add new default file thumbnails, both for generic and specific file types - - Change the unlinked file icon to better convey its meaning - - Add dropdown for different thumbnail sizes -- Show File Creation and Modified dates; Restyle file path label +- Copy and paste fields +- Add multiple fields at once +- Drag and drop files in/out of the program + - Files can be shared by dragging them from the thumbnail grid to other programs + - Files can be added to library folder by dragging them into the program +- Manage Python virtual environment in Nix flake +- Ability to create tag when adding tags +- Blender preview thumbnail support +- File deletion/trashing + - Added right-click option on thumbnails and preview panel to delete files + - Added Edit Menu option for deleting files + - Added Delete key shortcut for deleting files +- Font preview thumbnail support + - Short "Aa" previews for thumbnails + - Full alphabet preview for the preview pane +- Sort tags by alphabetical/color +- File explorer action follows OS naming +- Preview Source Engine files +- Expanded thumbnail and preview features + - Add album cover art thumbnails + - Add audio waveform thumbnails for audio files without embedded cover art + - Add new default file thumbnails, both for generic and specific file types + - Change the unlinked file icon to better convey its meaning + - Add dropdown for different thumbnail sizes +- Show File Creation and Modified dates; Restyle file path label ### Fixed -- Backslashes in f-string on file dupe widget -- Tags not shown when none searched -- Avoid error from eagerly grabbing data values -- Correct behavior for tag search options -- Load Gallery-DL sidecar files correctly -- Correct duplicate file matching -- GPU hardware acceleration in Nix flake -- Suppress command prompt windows for FFmpeg in builds +- Backslashes in f-string on file dupe widget +- Tags not shown when none searched +- Avoid error from eagerly grabbing data values +- Correct behavior for tag search options +- Load Gallery-DL sidecar files correctly +- Correct duplicate file matching +- GPU hardware acceleration in Nix flake +- Suppress command prompt windows for FFmpeg in builds ### Internal Changes -- Move type constants to media classes -- Combine open launch arguments -- Revamp Nix flake with devenv/direnv in cb4798b -- Remove impurity of Nix flake when used with direnv in bc38e56 +- Move type constants to media classes +- Combine open launch arguments +- Revamp Nix flake with devenv/direnv in cb4798b +- Remove impurity of Nix flake when used with direnv in bc38e56 -## 9.3.2 [July 18th, 2024] +## 9.3.2 July 18th, 2024 ### Fixed -- Fix signal log warning -- Fix "Folders to Tags" feature -- Fix search ignoring case of extension list +- Fix signal log warning +- Fix "Folders to Tags" feature +- Fix search ignoring case of extension list ### Internal Changes -- Add tests into CI by -- Create testing library files ad-hoc -- Refactoring: centralize field IDs -- Update to pyside6 version 6.7.1 +- Add tests into CI by +- Create testing library files ad-hoc +- Refactoring: centralize field IDs +- Update to pyside6 version 6.7.1 --- -## 9.3.1 [June 13th, 2024] +## 9.3.1 June 13th, 2024 ### Fixed -- Separately pin QT nixpkg version -- Bugfix for #252, don't attempt to read video file if invalid or 0 frames long -- Toggle Mouse Event Transparency on ItemThumbs -- Refactor `video_player.py` +- Separately pin QT nixpkg version +- Bugfix for #252, don't attempt to read video file if invalid or 0 frames long +- Toggle Mouse Event Transparency on ItemThumbs +- Refactor `video_player.py` --- -## 9.3.0 [June 8th, 2024] +## 9.3.0 June 8th, 2024 ### Added -- Added playback previews for video files -- Added Boolean "and/or" search mode selection -- Added ability to scan and fix duplicate entries (not to be confused with duplicate files) from the "Fix Unlinked Entries" menu -- Added “Select All” (Ctrl+A / ⌘ Command+A) hotkey for the library grid view -- Added "Clear Selection" hotkey (Esc) for the library grid view -- Added the ability to invert the file extension inclusion list into an exclusion list -- Added default landing page when no library is open +- Added playback previews for video files +- Added Boolean "and/or" search mode selection +- Added ability to scan and fix duplicate entries (not to be confused with duplicate files) from the "Fix Unlinked Entries" menu +- Added “Select All” (Ctrl+A / ⌘ Command+A) hotkey for the library grid view +- Added "Clear Selection" hotkey (Esc) for the library grid view +- Added the ability to invert the file extension inclusion list into an exclusion list +- Added default landing page when no library is open ### Fixed -- TagStudio will no longer attempt to or allow you to reopen a library from a missing location -- Fixed `PermissionError` when attempting to access files with a higher permission level upon scanning the library directory -- Fixed RAW image previews sometimes not loadingand -- Fixed most non-UTF-8 encoded text files from not being able to be previewed -- Fixed "Refresh Directories"/"Fix Unlinked Entries" creating duplicate entries -- Other miscellaneous fixes +- TagStudio will no longer attempt to or allow you to reopen a library from a missing location +- Fixed `PermissionError` when attempting to access files with a higher permission level upon scanning the library directory +- Fixed RAW image previews sometimes not loadingand +- Fixed most non-UTF-8 encoded text files from not being able to be previewed +- Fixed "Refresh Directories"/"Fix Unlinked Entries" creating duplicate entries +- Other miscellaneous fixes ### Changed -- Renamed "Subtags" to "Parent Tags" to help better describe their function -- Increased number of tags shown by default in the "Add Tag" modal from 29 to 100 -- Documentation is now split into individual linked files and updated to include future features -- Replaced use of `os.path` with `pathlib` -- `.cr2` files are now included in the list of RAW image file types -- Minimum supported macOS version raised to 12.0 +- Renamed "Subtags" to "Parent Tags" to help better describe their function +- Increased number of tags shown by default in the "Add Tag" modal from 29 to 100 +- Documentation is now split into individual linked files and updated to include future features +- Replaced use of `os.path` with `pathlib` +- `.cr2` files are now included in the list of RAW image file types +- Minimum supported macOS version raised to 12.0 --- -## 9.2.1 [May 23rd, 2024] +## 9.2.1 May 23rd, 2024 ### Added -- Basic thumbnail/preview support for RAW images (currently `.raw`, `.dng`, `.rw2`, `.nef`, `.arw`, `.crw`, `.cr3`) - - NOTE: These previews are currently slow to load given the nature of rendering them. In the future once thumbnail caching is added, this process should only happen once. -- Thumbnail/preview support for HEIF images +- Basic thumbnail/preview support for RAW images (currently `.raw`, `.dng`, `.rw2`, `.nef`, `.arw`, `.crw`, `.cr3`) + - NOTE: These previews are currently slow to load given the nature of rendering them. In the future once thumbnail caching is added, this process should only happen once. +- Thumbnail/preview support for HEIF images ### Fixed -- Fixed sidebar not expanding horizontally -- Fixed "Recent Library" list not updating when creating a new library -- Fixed palletized images not loading with alpha channels -- Low resolution images (such as pixel art) now render with crisp edges in thumbnails and previews -- Fixed visual bug where the edit icon would show for incorrect fields +- Fixed sidebar not expanding horizontally +- Fixed "Recent Library" list not updating when creating a new library +- Fixed palletized images not loading with alpha channels +- Low resolution images (such as pixel art) now render with crisp edges in thumbnails and previews +- Fixed visual bug where the edit icon would show for incorrect fields --- -## 9.2.0 [May 14th, 2024] +## 9.2.0 May 14th, 2024 ### Added -- Full macOS and Linux support -- Ability to apply tags to multiple selections at once -- Right-click context menu for opening files or their locations -- Support for all filetypes inside of the library -- Configurable filetype blacklist -- Option to automatically open last used library on startup -- Tool to convert folder structure to tag tree -- SIGTERM handling in console window -- Keyboard shortcuts for basic functions -- Basic support for plaintext thumbnails -- Default icon for files with no thumbnail support -- Menu action to close library -- All tags now show in the "Add Tag" panel by default -- Modal view to view and manage all library tags -- Build scripts for Windows and macOS -- Help menu option to visit the GitHub repository -- Toggleable "Recent Libraries" list in the entry side panel +- Full macOS and Linux support +- Ability to apply tags to multiple selections at once +- Right-click context menu for opening files or their locations +- Support for all filetypes inside of the library +- Configurable filetype blacklist +- Option to automatically open last used library on startup +- Tool to convert folder structure to tag tree +- SIGTERM handling in console window +- Keyboard shortcuts for basic functions +- Basic support for plaintext thumbnails +- Default icon for files with no thumbnail support +- Menu action to close library +- All tags now show in the "Add Tag" panel by default +- Modal view to view and manage all library tags +- Build scripts for Windows and macOS +- Help menu option to visit the GitHub repository +- Toggleable "Recent Libraries" list in the entry side panel ### Fixed -- Fixed errors when performing actions with no library open -- Fixed bug where built-in tags were duplicated upon saving -- QThreads are now properly terminated on application exit -- Images with rotational EXIF data are now properly displayed -- Fixed "truncated" images causing errors -- Fixed images with large resolutions causing errors +- Fixed errors when performing actions with no library open +- Fixed bug where built-in tags were duplicated upon saving +- QThreads are now properly terminated on application exit +- Images with rotational EXIF data are now properly displayed +- Fixed "truncated" images causing errors +- Fixed images with large resolutions causing errors ### Changed -- Updated minimum Python version to 3.12 -- Various UI improvements - - Improved legibility of the Light Theme (still a WIP) - - Updated Dark Theme - - Added hand cursor to several clickable elements -- Fixed network paths not being able to load -- Various code cleanup and refactoring -- New application icons +- Updated minimum Python version to 3.12 +- Various UI improvements + - Improved legibility of the Light Theme (still a WIP) + - Updated Dark Theme + - Added hand cursor to several clickable elements +- Fixed network paths not being able to load +- Various code cleanup and refactoring +- New application icons ### Known Issues -- Using and editing multiple entry fields of the same type may result in incorrect field(s) being updated -- Adding Favorite or Archived tags via the thumbnail badges may apply the tag(s) to incorrect fields -- Searching for tag names with spaces does not currently function as intended - - A temporary workaround it to omit spaces in tag names when searching -- Sorting fields using the "Sort Fields" macro may result in edit icons being shown for incorrect fields +- Using and editing multiple entry fields of the same type may result in incorrect field(s) being updated +- Adding Favorite or Archived tags via the thumbnail badges may apply the tag(s) to incorrect fields +- Searching for tag names with spaces does not currently function as intended + - A temporary workaround it to omit spaces in tag names when searching +- Sorting fields using the "Sort Fields" macro may result in edit icons being shown for incorrect fields --- -## 9.1.0 [April 22nd, 2024] +## 9.1.0 April 22nd, 2024 ### Added -- Initial public release +- Initial public release diff --git a/docs/colors.md b/docs/colors.md index 8563388fb..99d1ce89a 100644 --- a/docs/colors.md +++ b/docs/colors.md @@ -1,7 +1,11 @@ --- +title: Colors icon: material/palette --- + + + # :material-palette: Colors TagStudio features a variety of built-in tag colors, alongside the ability for users to create their own custom tag color palettes. diff --git a/docs/contributing.md b/docs/contributing.md index 133ffac86..d101f8079 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,154 +1,150 @@ --- +title: Contributing icon: material/file-plus --- -# :material-file-plus: Contributing + + -Thank you so much for showing interest in contributing to TagStudio! Here are a set of instructions and guidelines for contributing code or documentation to the project. This document will change over time, so make sure that your contributions still line up with the requirements here before submitting a pull request. +# :material-file-plus: Contribution Guidelines -## Getting Started +Thank you so much for showing interest in contributing to TagStudio! This page goes over the instructions and guidelines for contributing to the TagStudio. This page will change over time, so make sure that your contributions still line up with the current guidelines before submitting a pull request. -- Check the [Feature Roadmap](roadmap.md) page to see what priority features there are, the [FAQ](https://github.com/TagStudioDev/TagStudio/blob/main/README.md#faq), as well as the project's [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls). -- If you'd like to add a feature that isn't on the feature roadmap or doesn't have an open issue, **PLEASE create a feature request** issue for it discussing your intentions so any feedback or important information can be given by the team first. - - We don't want you wasting time developing a feature or making a change that can't/won't be added for any reason ranging from pre-existing refactors to design philosophy differences. -- **Please don't** create pull requests that consist of large refactors, _especially_ without discussing them with us first. These end up doing more harm than good for the project by continuously delaying progress and disrupting everyone else's work. -- If you wish to discuss TagStudio further, feel free to join the [Discord Server](https://discord.com/invite/hRNnVKhF2G)! +If you wish to discuss TagStudio further, feel free to join the [Discord Server](https://discord.com/invite/hRNnVKhF2G)! -### Contribution Checklist +## :material-order-bool-ascending-variant: Contribution Checklist -- I've read the [Feature Roadmap](roadmap.md) page -- I've read the [FAQ](https://github.com/TagStudioDev/TagStudio/blob/main/README.md#faq) -- I've checked the project's [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls) -- **I've created a new issue for my feature/fix _before_ starting work on it**, or have at least notified others in the relevant existing issue(s) of my intention to work on it -- I've set up my development environment including Ruff, Mypy, and PyTest -- I've read the CONTRIBUTING.md/Contributing page on the documentation site as well as the and/or [Style Guide](style.md) -- **_I mean it, I've found or created an issue for my feature/fix!_** +#### All Contributions - -!!! note - If the fix is small and self-explanatory (i.e. a typo), then it doesn't require an issue to be opened first. Issue tracking is supposed to make our lives easier, not harder. Please use your best judgement to minimize the amount of work for everyone involved. - -## Creating a Development Environment - -If you wish to develop for TagStudio, you'll need to create a development environment by installing the required dependencies. You have a number of options depending on your level of experience and familiarly with existing Python toolchains. - -If you know what you're doing and have developed for Python projects in the past, you can get started quickly with the "Brief Instructions" below. Otherwise, please see the full instructions on the documentation website for "[Creating a Development Environment](https://docs.tagstud.io/install/#creating-a-development-environment)". - -### Brief Instructions - -1. Have [Python 3.12](https://www.python.org/downloads/) and PIP installed. Also have [FFmpeg](https://ffmpeg.org/download.html) installed if you wish to have audio/video playback and thumbnails. -2. Clone the repository to the folder of your choosing: - ``` - git clone https://github.com/TagStudioDev/TagStudio.git - ``` -3. Use a dependency manager such as [uv](https://docs.astral.sh/uv/) or [Poetry 2.0](https://python-poetry.org/blog/category/releases/) to install the required dependencies, or alternatively create and activate a [virtual environment](https://docs.tagstud.io/install/#manual-installation) with `venv`. +- [x] I've read the Contribution Guidelines (this page) and the [Style Guide](style.md) +- [x] I've read the [FAQ](https://github.com/TagStudioDev/TagStudio/blob/main/README.md#faq) +- [x] I've checked the project's [pull requests](https://github.com/TagStudioDev/TagStudio/pulls) for any existing or conflicting PRs +- [x] I've set up my [development environment](developing.md) including [Ruff](developing.md#ruff), [Pyright](developing.md#pyright), and [Pytest](developing.md#pytest) -4. If using a virtual environment instead of a dependency manager, install an editable version of the program and development dependencies with the following PIP command: - - ``` - pip install -e ".[dev]" - ``` - - Otherwise, modify the command above for use with your dependency manager of choice. For example if using uv, you may use this: - - ``` - uv pip install -e ".[dev]" - ``` +#### Feature Additions -## Workflow Checks - -When pushing your code, several automated workflows will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests. +- [x] I've read the [Feature Roadmap](roadmap.md) and understand what core features are planned, their priorities, and their scheduled timelines +- [x] I've found an existing [feature request](https://github.com/TagStudioDev/TagStudio/issues) or created my own **_before starting work on a feature_** so that the feature can be discussed beforehand -!!! tip - To format the code automatically before each commit, there's a configured action available for the `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`. - -### [Ruff](https://github.com/astral-sh/ruff) +!!! danger "Before Developing Features" + **PLEASE** open a [feature request](https://github.com/TagStudioDev/TagStudio/issues) or ensure that one already exists **_before you begin work on the feature_**. This allows us to discuss the feature idea beforehand, approve or reject it, and give any specific implementation requirements. We **do not want** to have to close pull requests because they add features that conflict with the project's goals, guidelines, or other planned features. -A Python linter and code formatter. Ruff uses the `pyproject.toml` as its config file and runs whenever code is pushed or pulled into the project. +#### Fixes -#### Running Locally +- [x] I've found an existing [bug report](https://github.com/TagStudioDev/TagStudio/issues) or created my own for this fix _(as long as the fix is substantial enough to warrant opening a bug report for)_ -Inside the root repository directory: + +!!! note "Issue Exceptions for Small Fixes" + If the fix is small and self-explanatory (e.g. a typo), then it doesn't require an issue to be opened first. Issue tracking is supposed to make our lives easier, not harder. Please use your best judgement to minimize the amount of work for everyone involved. -- Lint code with `ruff check` - - Some linting suggestions can be automatically formatted with `ruff check --fix` -- Format code with `ruff format` +## :material-thumb-down:{.red} Unacceptable Code -Ruff should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file. For more information, see the [ruff configuration discovery docs](https://docs.astral.sh/ruff/configuration/#config-file-discovery). +The following types of code will **NOT** be accepted to the project: -Ruff is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/). +- Code that does not follow the [Contribution Checklist](#contribution-checklist) or violates the Contribution Guidelines +- Large refactors that have not been discussed with us first + - These types of refactors end up doing more harm than good for the project by continuously delaying progress and disrupting everyone else's work +- Other people/projects' code that is used without consent or does not have a [compatible license](#licenses) +- Code that you do not understand and/or cannot explain (i.e. "vibe coding") +- Code that adds a drastic amount of complexity with minimal utility -### [Mypy](https://github.com/python/mypy) +--- -Mypy is a static type checker for Python. It sure has a lot to say sometimes, but we recommend you take its advice when possible. Mypy also uses the `pyproject.toml` as its config file and runs whenever code is pushed or pulled into the project. +## :material-license: Licenses -#### Running Locally +As of May 2026, the TagStudio project has begun migrating to different licenses for different sections of the codebase where possible. Any new code contributed inside the "core" of TagStudio must be under the [MIT license](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSES/MIT.txt), while any code specific to the Qt frontend is to remain under [GPL-3.0](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSES/GPL-3.0-only.txt) where possible. -- **(First time only)** Run the following: - - `mkdir -p .mypy_cache` - - `mypy --install-types --non-interactive` -- You can now check code by running `mypy --config-file pyproject.toml .` in the repository root. _(Don't forget the "." at the end!)_ + +!!! question "Relicensing Process" + Existing GPL-3.0 core code is **only** migrated to MIT if all of the original contributors have given their consent, or if the code becomes replaced by a significantly different implementation. -Mypy is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=matangover.mypy), PyCharm [plugin](https://plugins.jetbrains.com/plugin/11086-mypy), and [more](https://plugins.jetbrains.com/plugin/11086-mypy). +Licensing is now accomplished using the [REUSE](https://reuse.software/spec-3.3/) specification. This means that each file is licensed separately, with text files having a comment header with the license and copyright and other files having this information declared in the [RESUSE.toml](https://github.com/TagStudioDev/TagStudio/blob/main/REUSE.toml) file. -### PyTest + +=== "Python GPL-3.0 Comment" + ```py + # SPDX-FileCopyrightText: (c) TagStudio Contributors + # SPDX-License-Identifier: GPL-3.0-only + ``` +=== "Python MIT Comment" + ```py + # SPDX-FileCopyrightText: (c) TagStudio Contributors + # SPDX-License-Identifier: MIT + ``` +=== "Markdown/HTML Comment" + ```html + + + ``` +=== "CSS Comment" + ```css + /* + * SPDX-FileCopyrightText: (c) TagStudio Contributors + * SPDX-License-Identifier: GPL-3.0-only + */ + ``` + -- Run all tests by running `pytest tests/` in the repository root. +#### Types of Files That Should Be MIT -## Code Style +- Backend code that supports TagStudio's core systems, irrespective of the frontend used + - e.g. The database backend for storing TagStudio data, the search query system, database tests, etc. +- Frontend code for the CLI _(to be developed)_ +- Platform-agnostic thumbnail extraction, rendering, and caching +- Translations added to an MIT-labeled [Weblate](https://hosted.weblate.org/projects/tagstudio/) component -See the [Style Guide](style.md) +#### Types of Files That Should Be GPL-3.0 -### Modules & Implementations +- Code for the Qt frontend + - e.g. Qt widgets, views, controllers, Qt rendering code, Qt tests, etc. -- **Do not** modify legacy library code in the `src/core/library/json/` directory -- Avoid direct calls to `os` - - Use `Pathlib` library instead of `os.path` - - Use `platform.system()` instead of `os.name` and `sys.platform` -- Don't prepend local imports with `tagstudio`, stick to `src` -- Use the `logger` system instead of `print` statements -- Avoid nested f-strings -- Use HTML-like tags inside Qt widgets over stylesheets where possible +--- -### Commit and Pull Request Style +## :material-code-braces-box: Commits and Pull Requests -- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) as a guideline for commit messages. This allows us to easily generate changelogs for releases. - - See some [examples](https://www.conventionalcommits.org/en/v1.0.0/#examples) of what this looks like in practice. -- Use clear and concise commit messages. If your commit does too much, either consider breaking it up into smaller commits or providing extra detail in the commit description. -- Pull requests should have an adequate title and description which clearly outline your intentions and changes/additions. Feel free to provide screenshots, GIFs, or videos, especially for UI changes. -- Pull requests should ideally be limited to **a single** feature or fix. +- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) as a guideline for commit messages. This allows us to easily generate changelogs for releases. + - See some [examples](https://www.conventionalcommits.org/en/v1.0.0/#examples) of what this looks like in practice. +- Use clear and concise commit messages. If your commit does too much, either consider breaking it up into smaller commits or providing extra detail in the commit description. +- Pull requests should have an adequate title and description which clearly outline your intentions and changes/additions. Feel free to provide screenshots, GIFs, or videos, especially for UI changes. +- Pull requests should ideally be limited to a **single** feature or fix. -!!! important +!!! danger "Force Pushing" **Please do not force push if your PR is open for review!** Force pushing makes it impossible to discern which changes have already been reviewed and which haven't. This means a reviewer will then have to re-review all the already reviewed code, which is a lot of unnecessary work for reviewers. -!!! tip +!!! tip "PR Scope" If you're unsure where to stop the scope of your PR, ask yourself: _"If I broke this up, could any parts of it still be used by the project in the meantime?"_ +### Workflow Checks + +When pushing your code, several automated [workflows](https://github.com/TagStudioDev/TagStudio/tree/main/.github/workflows) will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests. These checks currently include: + +- [Ruff](developing.md#ruff) [`check`](https://docs.astral.sh/ruff/linter) and [`format`](https://docs.astral.sh/ruff/formatter/) (read-only) +- [Pyright](developing.md#pyright) type checking +- [Pytest](developing.md#pytest) tests +- REUSE [license compliance](#licenses) + ### Runtime Requirements -- Final code must function on supported versions of Windows, macOS, and Linux: - - Windows: 10, 11 - - macOS: 13.0+ - - Linux: _Varies_ -- Final code must **_NOT:_** - - Contain superfluous or unnecessary logging statements - - Cause unreasonable slowdowns to the program outside of a progress-indicated task - - Cause undesirable visual glitches or artifacts on screen +Code must function on all of the supported operating systems and versions: + +- Windows 10 & 11 +- macOS 14.0+ +- Common Linux distributions and versions -## Documentation Guidelines +## :material-file-document: Documentation Guidelines -Documentation contributions include anything inside of the `docs/` folder, as well as the `README.md` and `CONTRIBUTING.md` files. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). +Documentation contributions include anything inside of the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). -- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names -- Follow the folder structure pattern -- Don't add images or other media with excessively large file sizes -- Provide alt text for all embedded media -- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization +- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names +- Follow the folder structure pattern +- Don't add images or other media with excessively large file sizes +- Provide alt text for embedded media +- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization -## Translation Guidelines +## :material-translate: Translation Guidelines Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/). diff --git a/docs/developing.md b/docs/developing.md index dc49ee23a..586866166 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -1,14 +1,18 @@ --- +title: Developing icon: material/code-braces --- + + + # :material-code-braces: Developing If you wish to develop for TagStudio, you'll need to create a development environment by installing the required dependencies. You have a number of options depending on your level of experience and familiarity with existing Python toolchains. !!! tip "Contributing" - If you wish to contribute to TagStudio's development, please read our [CONTRIBUTING.md](https://github.com/TagStudioDev/TagStudio/blob/main/CONTRIBUTING.md)! + If you wish to contribute to TagStudio's development, please read our [Contribution Guidelines](contributing.md)! ## Installing Python @@ -46,7 +50,7 @@ git clone https://github.com/TagStudioDev/TagStudio.git ## Installing Dependencies -To install the required dependencies, you can use a dependency manager such as [uv](https://docs.astral.sh/uv) or [Poetry 2.0](https://python-poetry.org). Alternatively you can create a virtual environment and manually install the dependencies yourself. +To install the required dependencies, you can use a dependency manager such as [uv](https://docs.astral.sh/uv) (recommended) or [Poetry 2.0](https://python-poetry.org). Alternatively you can create a virtual environment and manually install the dependencies yourself. ### Installing with uv @@ -56,7 +60,7 @@ If using [uv](https://docs.astral.sh/uv), you can install the dependencies for T uv pip install -e ".[dev]" ``` -A reference `.envrc` is provided for use with [direnv](#direnv), see [`contrib/.envrc-uv`](https://github.com/TagStudioDev/TagStudio/blob/main/contrib/.envrc-uv). +TagStudio should now be runnable using the `tagstudio` command. --- @@ -68,6 +72,8 @@ If using [Poetry](https://python-poetry.org), you can install the dependencies f poetry install --with dev ``` +TagStudio should now be runnable using the `tagstudio` command. + --- ### Manual Installation @@ -85,10 +91,9 @@ If you choose to manually set up a virtual environment and install dependencies ``` 2. Activate your environment: - - - Windows w/Powershell: `.venv\Scripts\Activate.ps1` - - Windows w/Command Prompt: `.venv\Scripts\activate.bat` - - Linux/macOS: `source .venv/bin/activate` + - Windows w/Powershell: `.venv\Scripts\Activate.ps1` + - Windows w/Command Prompt: `.venv\Scripts\activate.bat` + - Linux/macOS: `source .venv/bin/activate` !!! info "Supported Shells" @@ -107,6 +112,12 @@ If you choose to manually set up a virtual environment and install dependencies pip install -e ".[dev]" ``` +4. TagStudio should now be runnable using the `tagstudio` command. + + +!!! Warning "Linux Library Dependencies" + If developing TagStudio on Linux, certain libraries are required that may not be included with your distribution. A full list of these can be found [here](install.md#linux). + ## Nix(OS) If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command: @@ -142,9 +153,54 @@ The entry point for TagStudio is `src/tagstudio/main.py`. You can target this fi } ``` + +!!! tip + To format the code automatically before each commit, there's a configured action available for the `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`. + +### Ruff + +[Ruff](https://github.com/astral-sh/ruff) is a Python linter and code formatter that helps enforce a consistent formatting style across our codebase. + +Ruff is installed alongside the `pip install -e ".[dev]"` command, but is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/). + +```sh title="Lint Code" +ruff check + +# Apply automatic fixes with +ruff check --fix +``` + +```sh title="Format Code" +ruff format +``` + +Ruff should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file. For more information, see the [ruff configuration discovery docs](https://docs.astral.sh/ruff/configuration/#config-file-discovery). + +### Pyright + +[Pyright](https://github.com/microsoft/pyright) is a static type checker for Python that helps enforce type strictness and prevent easy-to-miss errors across our codebase. + +Pyright is installed alongside the `pip install -e ".[dev]"` command, but is also available as VS Code extensions (see the [Pyright](https://marketplace.visualstudio.com/items?itemName=ms-pyright.pyright), [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), and [basedpyright](https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright) extensions), a PyCharm [setting](https://www.jetbrains.com/help/pycharm/lsp-tools.html#pyright), or in the form of forks such as [basedpyright](https://docs.basedpyright.com/latest/). + +```sh title="Run Checks" +pyright +``` + +Pyright/basedpyright should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file. + +### Pytest + +[Pytest](https://github.com/pytest-dev/pytest) runs our Python code against the tests inside the [`tests/`](https://github.com/TagStudioDev/TagStudio/tree/main/tests) directory. + +Pytest is installed alongside the `pip install -e ".[dev]"` command. + +```sh title="Run Tests" +pytest tests/ +``` + ### pre-commit -There is a [pre-commit](https://pre-commit.com/) configuration that will run through some checks before code is committed. Namely, mypy and the Ruff linter and formatter will check your code, catching those nits right away. +There is a [pre-commit](https://pre-commit.com/) configuration that will run through some checks before code is committed. Namely Pyright and Ruff will check your code, catching those nits right away. Once you have pre-commit installed, just run: diff --git a/docs/entries.md b/docs/entries.md index 6a1a9fbc9..d16442c31 100644 --- a/docs/entries.md +++ b/docs/entries.md @@ -1,7 +1,11 @@ --- +title: Entries icon: material/file --- + + + # :material-file: Entries Entries are the individual representations of your files inside a TagStudio [library](./index.md). Each one corresponds one-to-one to a file on disk, and tracks all of the additional [tags](tags.md) and metadata that you attach to it inside TagStudio. @@ -22,33 +26,33 @@ To fix file entries that have become unlinked, select the "Fix Unlinked Entries" ## Internal Structure -- `id` (`INTEGER`/`int`, `UNIQUE`, `NOT NULL`, `PRIMARY KEY`) - - The ID for the file entry. - - Used for guaranteed unique references. -- `folder` (`INTEGER`/`int`, `NOT NULL`, `FOREIGN KEY`) - - _Not currently used, may be removed._ -- `path` (`VARCHAR`/`Path`, `UNIQUE`, `NOT NULL`) - - The filename and filepath relative to the root of the library folder. - - (E.g. for library "Folder", path = "any_subfolders/filename.txt") -- `suffix` (`VARCHAR`/`str`, `NOT NULL`) - - The filename suffix with no leading dot. - - Used for quicker file extension checks. -- `date_created` (`DATETIME`/`Datetime`) - - _Not currently used, will be implemented in an upcoming update._ - - The creation date of the file (not the entry). - - Generated from `st_birthtime` on Windows and Mac, and `st_ctime` on Linux. -- `date_modified` (`DATETIME`/`Datetime`) - - _Not currently used, will be implemented in an upcoming update._ - - The latest modification date of the file (not the entry). - - Generated from `st_mtime`. -- `date_added` (`DATETIME`/`Datetime`) - - The date the file entry was added to the TagStudio library. +- `id` (`INTEGER`/`int`, `UNIQUE`, `NOT NULL`, `PRIMARY KEY`) + - The ID for the file entry. + - Used for guaranteed unique references. +- `folder` (`INTEGER`/`int`, `NOT NULL`, `FOREIGN KEY`) + - _Not currently used, may be removed._ +- `path` (`VARCHAR`/`Path`, `UNIQUE`, `NOT NULL`) + - The filename and filepath relative to the root of the library folder. + - (E.g. for library "Folder", path = "any_subfolders/filename.txt") +- `suffix` (`VARCHAR`/`str`, `NOT NULL`) + - The filename suffix with no leading dot. + - Used for quicker file extension checks. +- `date_created` (`DATETIME`/`Datetime`) + - _Not currently used, will be implemented in an upcoming update._ + - The creation date of the file (not the entry). + - Generated from `st_birthtime` on Windows and Mac, and `st_ctime` on Linux. +- `date_modified` (`DATETIME`/`Datetime`) + - _Not currently used, will be implemented in an upcoming update._ + - The latest modification date of the file (not the entry). + - Generated from `st_mtime`. +- `date_added` (`DATETIME`/`Datetime`) + - The date the file entry was added to the TagStudio library. ### Table Relationships -- `tag_entries` - - A relationship between `entry_id` to `tag_id`s from the `tags` table. -- `text_fields` - - (TODO: determine the relationship for `entry_id`) -- `datetime_fields` - - (TODO: determine the relationship for `entry_id`) +- `tag_entries` + - A relationship between `entry_id` to `tag_id`s from the `tags` table. +- `text_fields` + - (TODO: determine the relationship for `entry_id`) +- `datetime_fields` + - (TODO: determine the relationship for `entry_id`) diff --git a/docs/fields.md b/docs/fields.md index afcf057e4..768e61a8c 100644 --- a/docs/fields.md +++ b/docs/fields.md @@ -1,7 +1,11 @@ --- +title: Fields icon: material/text-box --- + + + # :material-text-box: Fields Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file. @@ -12,16 +16,16 @@ Fields are additional types of metadata that you can attach to [file entries](./ A string of text, displayed as a single line. -- e.g: Title, Author, Artist, URL, etc. +- e.g: Title, Author, Artist, URL, etc. ### Text Box A long string of text displayed as a box of text. -- e.g: Description, Notes, etc. +- e.g: Description, Notes, etc. ### Datetime A date and time value. -- e.g: Date Published, Date Taken, etc. +- e.g: Date Published, Date Taken, etc. diff --git a/docs/help/ffmpeg.md b/docs/help/ffmpeg.md index 80746c6f6..066dfd90b 100644 --- a/docs/help/ffmpeg.md +++ b/docs/help/ffmpeg.md @@ -1,7 +1,11 @@ --- +title: Installing FFmpeg icon: material/movie-open-cog --- + + + # :material-movie-open-cog: Installing FFmpeg FFmpeg is required for thumbnail previews and playback features on audio and video files. FFmpeg is a free Open Source project dedicated to the handling of multimedia (video, audio, etc) files. For more information, see their official website at [ffmpeg.org](https://www.ffmpeg.org/). @@ -23,7 +27,6 @@ To Install: 1. Download 7z or zip file and extract it (right click > Extract All) 2. Move extracted contents to a unique folder (i.e; `c:\ffmpeg` or `c:\Program Files\ffmpeg`) 3. Add FFmpeg to your system PATH - 1. In Windows, search for or go to "Edit the system environment variables" under the Control Panel 2. Under "User Variables", select "Path" then edit 3. Click new and add `\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`) diff --git a/docs/ignore.md b/docs/ignore.md index 0e7687d03..229303bb8 100644 --- a/docs/ignore.md +++ b/docs/ignore.md @@ -3,6 +3,9 @@ title: Ignoring Files icon: material/file-document-remove --- + + + # :material-file-document-remove: Ignoring Files & Directories @@ -62,7 +65,7 @@ When scanning your library directories, the `.ts_ignore` file is read by either A `#` symbol at the start of a line indicates that this line is a comment, and match no items. Blank lines are used to enhance readability and also match no items. -- Can be escaped by putting a backslash ("`\`") in front of the `#` symbol. +- Can be escaped by putting a backslash ("`\`") in front of the `#` symbol. === "Example comment" @@ -104,9 +107,9 @@ A `#` symbol at the start of a line indicates that this line is a comment, and m The forward slash "`/`" is used as the directory separator. Separators may occur at the beginning, middle or end of the `.ts_ignore` search pattern. -- If there is a separator at the beginning or middle (or both) of the pattern, then the pattern is relative to the directory level of the particular `.TagStudio` library folder itself. Otherwise the pattern may also match at any level below the `.TagStudio` folder level. +- If there is a separator at the beginning or middle (or both) of the pattern, then the pattern is relative to the directory level of the particular `.TagStudio` library folder itself. Otherwise the pattern may also match at any level below the `.TagStudio` folder level. -- If there is a separator at the end of the pattern then the pattern will only match directories, otherwise the pattern can match both files and directories. +- If there is a separator at the end of the pattern then the pattern will only match directories, otherwise the pattern can match both files and directories. === "Example folder pattern" @@ -127,8 +130,8 @@ The forward slash "`/`" is used as the directory separator. Separators may occur A `!` prefix before a pattern negates the pattern, allowing any files matched matched by previous patterns to be un-matched. -- Any matching file excluded by a previous pattern will become included again. -- **It is not possible to re-include a file if a parent directory of that file is excluded.** +- Any matching file excluded by a previous pattern will become included again. +- **It is not possible to re-include a file if a parent directory of that file is excluded.** === "Example negation" @@ -202,10 +205,10 @@ The character "`?`" matches any one character except "`/`". Two consecutive asterisks ("`**`") in patterns matched against full pathname may have special meaning: -- A leading "`**`" followed by a slash means matches in all directories. -- A trailing "`/**`" matches everything inside. -- A slash followed by two consecutive asterisks then a slash ("`/**/`") matches zero or more directories. -- Other consecutive asterisks are considered regular asterisks and will match according to the previous rules. +- A leading "`**`" followed by a slash means matches in all directories. +- A trailing "`/**`" matches everything inside. +- A slash followed by two consecutive asterisks then a slash ("`/**/`") matches zero or more directories. +- Other consecutive asterisks are considered regular asterisks and will match according to the previous rules. === "Leading **" diff --git a/docs/index.md b/docs/index.md index 35e761f4a..82ee83f73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,9 @@ hide: - navigation --- + + + # @@ -36,7 +39,7 @@ hide:
-- :material-file-multiple:{ .lg .middle } **[All Files](entries.md) Welcome** +- :material-file-multiple:{ .lg .middle } **[All Files](entries.md) Welcome** *** @@ -44,27 +47,25 @@ hide: [:material-arrow-right: See Full Preview Support](preview-support.md) -- :material-tag-text:{ .lg .middle } **Create [Tags](tags.md) Your Way** +- :material-tag-text:{ .lg .middle } **Create [Tags](tags.md) Your Way** *** + - :material-format-font: No character restrictions + - :material-form-textbox: Add aliases/alternate names + - :material-palette: Customize colors and styles + - :material-tag-multiple: Tags can be tagged with other tags! + - :material-star-four-points: And more! - - :material-format-font: No character restrictions - - :material-form-textbox: Add aliases/alternate names - - :material-palette: Customize colors and styles - - :material-tag-multiple: Tags can be tagged with other tags! - - :material-star-four-points: And more! - -- :material-magnify:{ .lg .middle } **Powerful [Search](search.md)** +- :material-magnify:{ .lg .middle } **Powerful [Search](search.md)** *** + - Full [Boolean operator](search.md) support + - Filenames, paths, and extensions with [glob]() syntax + - General media types (e.g. "Photo", "Video", "Document") + - Special searches (e.g. "Untagged") + - "[Smartcase](search.md#case-sensitivity)" case sensitivity - - Full [Boolean operator](search.md) support - - Filenames, paths, and extensions with [glob]() syntax - - General media types (e.g. "Photo", "Video", "Document") - - Special searches (e.g. "Untagged") - - "[Smartcase](search.md#case-sensitivity)" case sensitivity - -- :material-text-box:{ .lg .middle } **Text and Date [Fields](fields.md)** +- :material-text-box:{ .lg .middle } **Text and Date [Fields](fields.md)** *** @@ -78,7 +79,7 @@ hide:
-- :material-scale-balance:{ .lg .middle } **Open Source** +- :material-scale-balance:{ .lg .middle } **Open Source** *** @@ -88,11 +89,11 @@ hide: [:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#core-library-api) -- :material-database:{ .lg .middle } **Central Save File** +- :material-database:{ .lg .middle } **Central Save File** *** - Apposed to filling your drives with [sidecar files](https://en.wikipedia.org/wiki/Sidecar_file), TagStudio uses a project-like [library](libraries.md) system that stores your tags and metadata inside a single save file per-library. + Opposed to filling your drives with [sidecar files](https://en.wikipedia.org/wiki/Sidecar_file), TagStudio uses a project-like [library](libraries.md) system that stores your tags and metadata inside a single save file per-library. [:material-arrow-right: Learn About the Format](libraries.md) @@ -106,6 +107,6 @@ TagStudio aims to create an **open** and **robust** format for file tagging that
-- :material-map-check:{ .lg .middle } See the [**Roadmap**](roadmap.md) for future features and updates +- :material-map-check:{ .lg .middle } See the [**Roadmap**](roadmap.md) for future features and updates
diff --git a/docs/install.md b/docs/install.md index bec72c2bf..1426c44bd 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,7 +1,11 @@ --- +title: Installation icon: material/download --- + + + # :material-download: Installation TagStudio provides executable [releases](https://github.com/TagStudioDev/TagStudio/releases) as well as full access to its [source code](https://github.com/TagStudioDev/TagStudio) under the [GPLv3](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE) license. @@ -62,22 +66,21 @@ TagStudio can now be launched via the `tagstudio` command in your terminal. Some external dependencies are required for TagStudio to execute. Below is a table of known packages that will be necessary. - -| Package | Reason | -|--------------- | --------------- | -| [dbus](https://repology.org/project/dbus) | required for Qt; opening desktop applications | -| [ffmpeg](https://repology.org/project/ffmpeg) | audio/video playback | -| libstdc++ | required for Qt | -| [libva](https://repology.org/project/libva) | hardware rendering with [VAAPI](https://www.freedesktop.org/wiki/Software/vaapi) | -| [libvdpau](https://repology.org/project/libvdpau) | hardware rendering with [VDPAU](https://www.freedesktop.org/wiki/Software/VDPAU) | -| [libx11](https://repology.org/project/libx11) | required for Qt | -| libxcb-cursor OR [xcb-util-cursor](https://repology.org/project/xcb-util-cursor) | required for Qt | -| [libxkbcommon](https://repology.org/project/libxkbcommon) | required for Qt | -| [libxrandr](https://repology.org/project/libxrandr) | hardware rendering | -| [pipewire](https://repology.org/project/pipewire) | PipeWire audio support | -| [qt](https://repology.org/project/qt) | required | -| [qt-multimedia](https://repology.org/project/qt) | required | -| [qt-wayland](https://repology.org/project/qt) | Wayland support | +| Package | Reason | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [dbus](https://repology.org/project/dbus) | required for Qt; opening desktop applications | +| [ffmpeg](https://repology.org/project/ffmpeg) | audio/video playback | +| libstdc++ | required for Qt | +| [libva](https://repology.org/project/libva) | hardware rendering with [VAAPI](https://www.freedesktop.org/wiki/Software/vaapi) | +| [libvdpau](https://repology.org/project/libvdpau) | hardware rendering with [VDPAU](https://www.freedesktop.org/wiki/Software/VDPAU) | +| [libx11](https://repology.org/project/libx11) | required for Qt | +| libxcb-cursor OR [xcb-util-cursor](https://repology.org/project/xcb-util-cursor) | required for Qt | +| [libxkbcommon](https://repology.org/project/libxkbcommon) | required for Qt | +| [libxrandr](https://repology.org/project/libxrandr) | hardware rendering | +| [pipewire](https://repology.org/project/pipewire) | PipeWire audio support | +| [qt](https://repology.org/project/qt) | required | +| [qt-multimedia](https://repology.org/project/qt) | required | +| [qt-wayland](https://repology.org/project/qt) | Wayland support | ### :material-nix: Nix(OS) @@ -223,20 +226,37 @@ For audio/video thumbnails and playback you'll need [FFmpeg](https://ffmpeg.org/ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extractor capable of handling them. -- :material-penguin: On Linux you'll need to install either `unrar` (likely in you distro's non-free repository) or `unrar-free` from your package manager. +- :material-penguin: On Linux you'll need to install either `unrar` (likely in you distro's non-free repository) or `unrar-free` from your package manager. +- :fontawesome-brands-apple: On macOS `unrar` can be installed through Homebrew's [`rar`](https://formulae.brew.sh/cask/rar) formula. -- :fontawesome-brands-apple: On macOS `unrar` can be installed through Homebrew's [`rar`](https://formulae.brew.sh/cask/rar) formula. - - - !!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup" - On macOS, you may be met with a message similar to "**"unrar" Not Opened. Apple could not verify "unrar" is free of malware that may harm your Mac or compromise your privacy**" If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says "**"unrar" was blocked from use because it is not from an identified developer.**" Click the "Open Anyway" button to allow unrar to be used. + +!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup" + On macOS, you may be met with a message similar to "**"unrar" Not Opened. Apple could not verify "unrar" is free of malware that may harm your Mac or compromise your privacy**" If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says "**"unrar" was blocked from use because it is not from an identified developer.**" Click the "Open Anyway" button to allow unrar to be used. -- :fontawesome-brands-windows: On Windows you'll need to install either [`WinRAR`](https://www.rarlab.com/download.htm) or [`7-zip`](https://www.7-zip.org/) and add their folder to you `PATH`. +- :fontawesome-brands-windows: On Windows you'll need to install either [`WinRAR`](https://www.rarlab.com/download.htm) or [`7-zip`](https://www.7-zip.org/) and add their folder to you `PATH`. - - !!! tip "WinRAR License" - Both `unrar` and `WinRAR` require a license, but since the evaluation copy has no time limit you can simply dismiss the prompt. + +!!! tip "WinRAR License" + Both `unrar` and `WinRAR` require a license, but since the evaluation copy has no time limit you can simply dismiss the prompt. ### ripgrep A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers. + +## Common Error Messages + +### Could not load the Qt platform plugin "xcb" + +If you get an error message like this one: + +``` +qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin. +qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "/tmp/_MEIayuTiW/cv2/qt/plugins" even though it was found. +This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem. + +Available platform plugins are: vnc, wayland-egl, offscreen, wayland, linuxfb, minimalegl, eglfs, minimal, vkkhrdisplay, xcb. + +Aborted (core dumped) +``` + +Make sure you installed `libxcb-cursor` or `xcb-util-cursor`. diff --git a/docs/libraries.md b/docs/libraries.md index 0afeb6ef8..4c0c77c28 100644 --- a/docs/libraries.md +++ b/docs/libraries.md @@ -1,7 +1,11 @@ --- +title: Libraries icon: material/database --- + + + # :material-database: Libraries diff --git a/docs/library-changes.md b/docs/library-changes.md index 6de13d274..66054b5d3 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -1,14 +1,18 @@ --- +title: Library Format icon: material/database-edit --- + + + # :material-database-edit: Library Format This page outlines the various changes made to the TagStudio library save file format over time, sometimes referred to as the "database" or "database file". --- -## JSON +## JSON v1.0.0 - v9.4.2 Legacy (JSON) library save format versions were tied to the release version of the program itself. This number was stored in a `version` key inside the JSON file. @@ -24,21 +28,30 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1] --- -## SQLite +## SQLite v9.5.0+ Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number. +### Versioning + Versions **1-100** stored the database version in a table called `preferences` in a row with the `key` column of `"DB_VERSION"` inside the corresponding `value` column. Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`. -```mermaid -erDiagram - versions { - TEXT key PK "Values: ['INITIAL', 'CURRENT']" - INTEGER value - } -``` +#### "versions" Table + +| key (`VARCHAR`) | value (`INTEGER`) | +| --------------- | --------------------------------------------- | +| `'INITIAL'` | | +| `'CURRENT'` | | + +#### Major and Minor Versioning + +Version **100** came along with a major/minor versioning system built into to the single version number. The version number divided by 100 denotes the major version, while remaining digits denote the minor version. TagStudio will allow reading from "future" databases so long as the major version does not increase past the last one it understands. + +For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display. + +--- ### Versions 1 - 5 @@ -64,8 +77,8 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key. -- Repairs tags that may have a disambiguation_id pointing towards a deleted tag. +- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_ +- Repairs tags that may have a disambiguation_id pointing towards a deleted tag. --- @@ -75,9 +88,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior. -- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)". -- Updates Neon colors to use the new `color_border` property. +- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior. +- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)". +- Updates Neon colors to use the new `color_border` property. --- @@ -87,48 +100,86 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results. +- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results. --- -### Version 100 +### Versions 100 - 104 + +#### Version 100 -| Used From | Format | Location | -| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +| ---------------------------------------- | ------ | ----------------------------------------------- | +| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | SQLite | ``/.TagStudio/ts_library.sqlite | -- Introduces built-in minor versioning - - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in. - - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased. -- Swaps `parent_id` and `child_id` values in the `tag_parents` table +- Introduces built-in minor versioning + - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in. + - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased. +- Swaps `parent_id` and `child_id` values in the `tag_parents` table #### Version 101 -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Deprecates the `preferences` table, set to be removed in a future TagStudio version. -- Introduces the `versions` table - - Has a string `key` column and an int `value` column - - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'` - - `'INITIAL'` stores the database version number in which in was created - - Pre-existing databases set this number to `100` - - `'CURRENT'` stores the current database version number +- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version. +- Introduces the `versions` table + - Has a string `key` column and an int `value` column + - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'` + - `'INITIAL'` stores the database version number in which in was created + - Pre-existing databases set this number to `100` + - `'CURRENT'` stores the current database version number #### Version 102 -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +| ---------------------------------------- | ------ | ----------------------------------------------- | +| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | 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. +- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. -#### Version 103 +| Used From | Format | Location | +| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | ``/.TagStudio/ts_library.sqlite | -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | -| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | ``/.TagStudio/ts_library.sqlite | +- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches. +- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default. + +#### Version 104 + +| Used From | Format | Location | +| ---------------------------------------- | ------ | ----------------------------------------------- | +| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | ``/.TagStudio/ts_library.sqlite | + +- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary. + +--- + +### Versions 200 - 2xx + +#### Version 200 + +| Used From | Format | Location | +| ---------------------------------------- | ------ | ----------------------------------------------- | +| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | ``/.TagStudio/ts_library.sqlite | + +- Adds `text_field_templates` and `date_field_templates` tables. +- Drops `boolean_fields` and `value_type` tables. +- Adds `name` columns to `text_fields` and `datetime_fields` tables. + - Values in the `name` columns are taken from the `type_key` columns and are changed to "Title Case". + - **Example:** "DATE_CREATED" -> "Date Created" +- Drops `position` columns from `text_fields` and `datetime_fields` tables. +- Adds `is_multiline` column to `text_fields` table. + - Values are set to `TRUE` if the field row was previously a "TEXT_BOX" type. +- Repairs existing "Description" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE` _(Previously done in [Version 7](#version-7))_. +- Repairs existing "Comments" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE`. + +#### Version 201 + +| Used From | Format | Location | +| ---------------------------------------- | ------ | ----------------------------------------------- | +| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches. -- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default. \ No newline at end of file +- Drops `type_key` columns from `text_fields` and `datetime_fields` tables. +- Enforces column positions for `text_fields` and `datetime_fields` tables. diff --git a/docs/macros.md b/docs/macros.md index 9268d267a..bc45059bb 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,7 +1,11 @@ --- +title: Tools & Macros icon: material/script-text --- + + + # :material-script-text: Tools & Macros Tools and macros are features that serve to create a more fluid [library](libraries.md)-managing process, or provide some extra functionality. Please note that some are still in active development and will be fleshed out in future updates. diff --git a/docs/preview-support.md b/docs/preview-support.md index e7d22a43c..7565fbc21 100644 --- a/docs/preview-support.md +++ b/docs/preview-support.md @@ -1,7 +1,11 @@ --- +title: Supported Previews icon: material/image-check --- + + + # :material-image-check: Supported Previews TagStudio offers built-in preview and thumbnail support for a wide variety of file types. Files that don't have explicit support can still be added to your library like normal, they will just show a default icon for thumbnails and previews. TagStudio also references the file's [MIME](https://en.wikipedia.org/wiki/Media_type) type in an attempt to render previews for file types that haven't gained explicit support yet. @@ -78,27 +82,41 @@ Audio thumbnails will default to embedded cover art (if any) and fallback to gen Preview support for office documents or well-known project file formats varies by the format and whether or not embedded thumbnails are available to be read from. OpenDocument-based files are typically supported. -| Filetype | Extensions | Preview Type | -| ----------------------------- | --------------------- | -------------------------------------------------------------------------- | -| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | -| Keynote (Apple iWork) | `.key` | Embedded thumbnail | -| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | -| MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | -| Numbers (Apple iWork) | `.numbers` | Embedded thumbnail | -| OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail | -| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail | -| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail | -| Pages (Apple iWork) | `.pages` | Embedded thumbnail | -| PDF | `.pdf` | First page render | -| Photoshop | `.psd` | Flattened image render | -| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +| Filetype | Extensions | Preview Type | +| ------------------------------------ | --------------------- | -------------------------------------------------------------------------- | +| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +| Clip Studio Paint | `.clip` | Embedded thumbnail | +| Keynote (Apple iWork) | `.key` | Embedded thumbnail | +| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +| Mdipack (FireAlpaca, Medibang Paint) | `.mdp` | Embedded thumbnail | +| MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +| Numbers (Apple iWork) | `.numbers` | Embedded thumbnail | +| OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail | +| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail | +| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail | +| Pages (Apple iWork) | `.pages` | Embedded thumbnail | +| Paint.NET | `.pdn` | Embedded thumbnail | +| PDF | `.pdf` | First page render | +| Photoshop | `.psd` | Flattened image render | +| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | + +### :material-archive: Archives + +Archive thumbnails will display the first image from the archive within the Preview Panel. + +| Filetype | Extensions | +| -------- | -------------- | +| 7-Zip | `.7z`, `.s7z` | +| RAR | `.rar` | +| Tar | `.tar`, `.tgz` | +| Zip | `.zip` | ### :material-book: eBooks -| Filetype | Extensions | Preview Type | -| ------------------ | --------------------- | ---------------------------- | -| EPUB | `.epub` | Embedded cover | -| Comic Book Archive | `.cbr`, `.cbt` `.cbz` | Embedded cover or first page | +| Filetype | Extensions | Preview Type | +| ------------------ | ----------------------------- | ---------------------------- | +| EPUB | `.epub` | Embedded cover | +| Comic Book Archive | `.cbr`, `.cbt` `.cbz`, `.cb7` | Embedded cover or first page | ### :material-cube-outline: 3D Models @@ -106,6 +124,8 @@ Preview support for office documents or well-known project file formats varies b !!! failure "3D Model Support" TagStudio does not currently support previews for 3D model files *(outside of Blender project embedded thumbnails)*. This is on our [roadmap](roadmap.md#uiux) for a future release. + See the [GitHub discussion](https://github.com/TagStudioDev/TagStudio/discussions/1231) relating to status of this feature. + ### :material-format-font: Fonts Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Preview Panel. @@ -137,7 +157,7 @@ Text files render the first 256 bytes of text information to an image preview fo [^1]: - The `.jpg_large` extension is unofficial and instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since this mangled extension is still in circulation, TagStudio supports it. + The `.jpg_large` extension is unofficial and is instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since this mangled extension is still in circulation, TagStudio supports it. [^2]: Apple Lossless traditionally uses `.m4a` and `.caf` containers, but may unofficially use the `.alac` extension. The `.m4a` container is also used for separate compressed audio codecs. diff --git a/docs/roadmap.md b/docs/roadmap.md index f14173939..eeb0c86d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,11 @@ --- +title: Roadmap icon: material/map-check --- + + + # :material-map-check: Roadmap This page outlines the current and planned features required for TagStudio to be considered "feature complete" (v10.0.0). Features and changes are broken up by group in order to better assess the overall state of those features. [Priority levels](#priority-levels) and [version estimates](#version-estimates) are provided in order to give a rough idea of what's planned and when it may release. @@ -14,54 +18,60 @@ Planned features and changes are assigned **priority levels** to signify how imp !!! info "Priority Level Icons" - - :material-chevron-triple-up:{ .priority-high title="High Priority" } **High Priority** - Core features - - :material-chevron-double-up:{ .priority-med title="Medium Priority" } **Medium Priority** - Important, but not necessary - - :material-chevron-up:{ .priority-low title="Low Priority" } **Low Priority** - Just nice to have + - :material-chevron-triple-up:{ .priority-high title="High Priority" } **High Priority** - Core features + - :material-chevron-double-up:{ .priority-med title="Medium Priority" } **Medium Priority** - Important, but not necessary + - :material-chevron-up:{ .priority-low title="Low Priority" } **Low Priority** - Just nice to have ## Version Estimates -Features are given rough estimations for which version they will be completed in, and are listed next to their names (e.g. Feature **[v9.0.0]**). They are eventually replaced with links to the version changelog in which they were completed in, if applicable. +Features are given rough estimations for which version they will be completed in listed next to their names (e.g. Feature **[v9.0.0]**). When the feature is completed they're linked to their respective changelog release, if applicable. + +| Version Cycle | Focused Features | +| ---------------- | -------------------------------------------------------- | +| ~~Alpha v9.5.x~~ | ~~Migrate from JSON to SQLite database format~~ | +| Alpha v9.6.x | Necessary database changes for upcoming features | +| Alpha v9.7.x | Implement currently solidified features | +| Beta v9.8.x | Solidify remaining features and implementations | +| Beta v9.9.x | Make any additions and fixes from earlier release cycles | +| v10.0.x | Full release | !!! tip For a more definitive and up-to-date list of features planned for near-future updates, please reference the current GitHub [Milestones](https://github.com/TagStudioDev/TagStudio/milestones)! ---- - ## Core ### :material-database: SQL Library Database An improved SQLite-based library save file format in which legacy JSON libraries are be migrated to. -Must be finalized or deemed "feature complete" before other core features are developed or finalized. !!! note See the "[Library](#library)" section for features related to the library database rather than the underlying schema. -- [x] A SQLite-based library save file format **[[v9.5.0](changelog.md#950-march-3rd-2025)]** -- [ ] Cached File Properties Table :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [x] Date Entry Added to Library :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Date File Created :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Date File Modified :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Date Photo Taken :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Media Duration :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Media Dimensions :material-chevron-double-up:{ .priority-med title="Medium Priority" } - - [ ] Word Count :material-chevron-up:{ .priority-low title="Low Priority" } +- [x] A SQLite-based library save file format **[[v9.5.0](changelog.md#950-march-3rd-2025)]** +- [ ] Cached File Properties Table :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.0]** + - [x] Date Entry Added to Library + - [ ] Date File Created :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Date File Modified :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Date Photo Taken :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Media Duration :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Media Dimensions :material-chevron-up:{ .priority-low title="Low Priority" } + - [ ] Word Count :material-chevron-up:{ .priority-low title="Low Priority" } -### :material-database-cog: Core Library + API +### :material-database-cog: Core Library + CLI -A separated, UI agnostic core library that would be used to interface with the TagStudio library format. Would host an API for communication from outside the program. This would be licensed under the more permissive [MIT](https://en.wikipedia.org/wiki/MIT_License) license to foster wider adoption compared to the TagStudio application source code. +A separated, UI agnostic core library that would be used to interface with the TagStudio library format. Would come with a CLI to allow for interfacing with scripts and external programs, and to make bulk operations easier. This would be licensed under the more permissive [MIT](https://en.wikipedia.org/wiki/MIT_License) license to foster wider adoption compared to the TagStudio GUI application source code. -- [ ] Core Library :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** -- [ ] Core Library API :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** -- [ ] MIT License :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** +- [ ] Core Library :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.0]** +- [ ] CLI :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]** +- [ ] MIT License :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]** ### :material-clipboard-text: Format Specification A detailed written specification for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable. -- [ ] Format Specification Established :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** +- [ ] Format Specification Established :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v10.0.0]** --- @@ -69,215 +79,212 @@ A detailed written specification for the TagStudio tag and/or library format. In ### :material-button-cursor: UI/UX -- [x] Library Grid View - - [ ] Explore Filesystem in Grid View :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Infinite Scrolling (No Pagination) :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] Library List View :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Explore Filesystem in List View :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] Lightbox View :material-chevron-triple-up:{ .priority-high title="High Priority" } - - Similar to List View in concept, but displays one large preview that can cycle back/forth between entries. - - [ ] Smaller thumbnails of immediate adjacent entries below :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [x] Library Statistics Screen **[[v9.5.4](changelog.md#954-september-1st-2025)]** -- [x] Unified Library Health/Cleanup Screen **[[v9.5.4](changelog.md#954-september-1st-2025)]** - - [x] Fix Unlinked Entries - - [x] Fix Duplicate Files - - [x] ~~Fix Duplicate Entries~~ - - [x] Remove Ignored Entries **[[v9.5.4](changelog.md#954-september-1st-2025)]** - - [x] Delete Old Backups **[[v9.5.4](changelog.md#954-september-1st-2025)]** - - [x] Delete Legacy JSON File **[[v9.5.4](changelog.md#954-september-1st-2025)]** -- [x] Translations -- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** - - [ ] Improved Tag Autocomplete :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Tags appear as widgets in search bar :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [x] Unified Media Player - - [x] Auto-Hiding Player Controls - - [x] Play/Pause - - [x] Loop - - [x] Toggle Autoplay - - [x] Volume Control - - [x] Toggle Mute - - [x] Timeline scrubber - - [ ] Fullscreen :material-chevron-double-up:{ .priority-med title="Medium Priority" } - - [ ] Fine-Tuned UI/UX :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6]** -- [ ] 3D Model Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] STL File Support - - [ ] OBJ File Support -- [ ] Plaintext Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [x] Basic Support - - [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Top Tags - - [ ] Recent Tags - - [ ] Tag Search - - [ ] Pinned Tags -- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" } -- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" } - - Would serve as an addition/alternative to the Favorite and Archived badges. +- [x] Library Grid View + - [ ] Explore Filesystem in Grid View :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [x] Infinite Scrolling (No Pagination) **[[9.5.6](changelog.md#956-october-20th-2025)]** +- [ ] Library List View :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Explore Filesystem in List View :material-chevron-double-up:{ .priority-med title="Medium Priority" } +- [ ] Lightbox View :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - Similar to List View in concept, but displays one large preview that can cycle back/forth between entries. + - [ ] Smaller thumbnails of immediate adjacent entries below :material-chevron-double-up:{ .priority-med title="Medium Priority" } +- [x] Library Statistics Screen **[[v9.5.4](changelog.md#954-september-1st-2025)]** +- [x] Unified Library Health/Cleanup Screen **[[v9.5.4](changelog.md#954-september-1st-2025)]** + - [x] Fix Unlinked Entries + - [ ] Fix Duplicate Files (Regression) **[v9.6.x]** + - [x] ~~Fix Duplicate Entries~~ + - [x] Remove Ignored Entries **[[v9.5.4](changelog.md#954-september-1st-2025)]** + - [x] Delete Old Backups **[[v9.5.4](changelog.md#954-september-1st-2025)]** + - [x] Delete Legacy JSON File **[[v9.5.4](changelog.md#954-september-1st-2025)]** +- [x] Translations +- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Improved Tag Autocomplete :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Tags appear as widgets in search bar :material-chevron-triple-up:{ .priority-high title="High Priority" } +- [x] Unified Media Player + - [x] Auto-Hiding Player Controls + - [x] Play/Pause + - [x] Loop + - [x] Toggle Autoplay + - [x] Volume Control + - [x] Toggle Mute + - [x] Timeline Scrubber + - [ ] Fullscreen Mode :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Fine-Tuned UI/UX :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** +- [ ] 3D Model Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" } _(See #1231)_ + - [ ] STL File Support + - [ ] OBJ File Support +- [ ] Plaintext Thumbnails/Previews + - [x] Basic Support + - [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.6.x]]** + - [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[[v9.6.x]]** +- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.8.x]]** + - [ ] Top Tags + - [ ] Recent Tags + - [ ] Tag Search + - [ ] Pinned Tags +- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]** +- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** +- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" } +- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" } + - Would serve as an addition/alternative to the Favorite and Archived badges. ### :material-cog: Settings -- [x] Application Settings - - [x] Stored in System User Folder/Designated Folder - - [x] Language - - [x] Date and Time Format - - [x] Theme - - [x] Thumbnail Generation **[[v9.5.4](changelog.md#954-september-1st-2025)]** -- [x] Configurable Page Size -- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Stored in `.TagStudio` folder :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] Toggle File Extension Label :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [ ] Toggle Duration Label :material-chevron-double-up:{ .priority-med title="Medium Priority" } - -### :material-puzzle: Plugin Support - -Some form of official plugin support for TagStudio, likely with its own API that may connect to or encapsulate part of the the [core library API](#core-library-api). - -- [ ] Plugin Support :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** +- [x] Application Settings + - [x] Stored in System User Folder/Designated Folder + - [x] Language + - [x] Date and Time Format + - [x] Theme + - [x] Thumbnail Generation **[[v9.5.4](changelog.md#954-september-1st-2025)]** +- [x] Configurable Page Size +- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Stored in `.TagStudio` folder :material-chevron-triple-up:{ .priority-high title="High Priority" } +- [ ] Toggle File Extension Label :material-chevron-double-up:{ .priority-med title="Medium Priority" } +- [ ] Toggle Duration Label :material-chevron-double-up:{ .priority-med title="Medium Priority" } --- -## [Library](libraries.md) +## Library ### :material-wrench: Library Mechanics -- [x] Per-Library Tags -- [ ] Global Tags :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Ability to store TagStudio library folder separate from library files :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** - - [ ] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Detect Deletions :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] Background File Scanning :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** -- [x] Thumbnail Caching **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [ ] Audio Waveform Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** - -### :material-grid: [Entries](entries.md) - -Library representations of files or file-like objects. - -- [x] File Entries **[v1.0.0]** -- [ ] Folder Entries :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } -- [x] Fields - - [x] Text Lines - - [x] Text Boxes - - [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]** - - [ ] User-Titled Fields :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Removal of Deprecated Fields :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** - - [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" } - - [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" } - -### :material-tag-text: [Tags](tags.md) +- [x] Per-Library Tags +- [ ] Global Tags :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.8.x]** +- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Ability to store TagStudio data folder separate from library content folder(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [ ] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]** + - [ ] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Detect Deletions :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" } +- [ ] Background File Scanning :material-chevron-triple-up:{ .priority-high title="High Priority" } +- [x] Thumbnail Caching **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [ ] Audio Waveform Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** + - [ ] Large Image Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** + +### :material-grid: Entries + +File or file-like [entries](entries.md) stored in the library. + +- [x] File Entries **[v1.0.0]** +- [ ] Folder Entries :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** +- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.7.x]** +- [x] Fields + - [x] Text Lines + - [x] Text Boxes + - [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]** + - [ ] Numeric Fields :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [x] Removal of Deprecated Fields **[v9.6.0]** +- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" } + +### :material-tag-text: Tags Discrete library objects representing [attributes](). Can be applied to library [entries](entries.md), or applied to other tags to build traversable relationships. -- [x] Tag Name **[v8.0.0]** -- [x] Tag Shorthand Name **[v8.0.0]** -- [x] Tag Aliases List **[v8.0.0]** -- [x] Tag Color **[v8.0.0]** -- [ ] Tag Description :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]** -- [x] Tag Colors - - [x] Built-in Color Palette **[v8.0.0]** - - [x] User-Defined Colors **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] Primary and Secondary Colors **[[v9.5.0](changelog.md#950-march-3rd-2025)]** -- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]** - - [ ] Built-in Icon Packs (i.e. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [x] [Category Property](tags.md#is-category) **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title - - [ ] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Hidden Property :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Built-in "Archived" tag has this property by default :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Checkbox near search bar to show hidden tags in search :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Tag Relationships - - [x] [Parent Tags](tags.md#parent-tags) ([Inheritance]() Relationship) **[v9.0.0]** - - [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Multiple Language Support :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.9.x]** -- [ ] Tag Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [ ] Tag Merging :material-chevron-double-up:{ .priority-med title="Medium Priority" } - -### :material-magnify: [Search](search.md) - -- [x] Tag Search **[v8.0.0]** -- [x] Filename Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] Glob Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** -- [x] Filetype Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] Search by Extension (e.g. ".jpg", ".png") **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] Search by media type (e.g. "image", "video", "document") **[[v9.5.0](changelog.md#950-march-3rd-2025)]** -- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [x] [Boolean Operators](search.md) **[[v9.5.0](changelog.md#950-march-3rd-2025)]** - - [x] `AND` Operator - - [x] `OR` Operator - - [x] `NOT` Operator - - [x] Parenthesis Grouping - - [x] Character Escaping -- [ ] `HAS` Operator (for [Component Tags](tags.md#component-tags)) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** -- [ ] Conditional Search :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** - - [ ] Compare Dates :material-chevron-double-up:{ .priority-med title="Medium Priority" } - - [ ] Compare Durations :material-chevron-double-up:{ .priority-med title="Medium Priority" } - - [ ] Compare File Sizes :material-chevron-double-up:{ .priority-med title="Medium Priority" } - - [ ] Compare Dimensions :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [x] Smartcase Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** -- [ ] Search Result Sorting - - [x] Sort by Filename **[[v9.5.2](changelog.md#952-march-31st-2025)]** - - [x] Sort by Date Entry Added to Library **[[v9.5.2](changelog.md#952-march-31st-2025)]** - - [ ] Sort by File Creation Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Sort by Date Taken (Photos) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** - - [x] Random/Shuffle Sort -- [ ] OCR Search :material-chevron-up:{ .priority-low title="Low Priority" } -- [ ] Fuzzy Search :material-chevron-up:{ .priority-low title="Low Priority" } - -### :material-file-cog: [Macros](macros.md) - -- [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]** -- [ ] Versioning System :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]** -- [ ] Triggers **[v9.5.x]** - - [ ] On File Added :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] On Library Refresh :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] [...] -- [ ] Actions **[v9.5.x]** - - [ ] Add Tag(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Add Field(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Set Field Content :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] [...] +- [x] Tag Name **[v8.0.0]** +- [x] Tag Shorthand Name **[v8.0.0]** +- [x] Tag Aliases List **[v8.0.0]** +- [x] Tag Color **[v8.0.0]** +- [ ] Tag Description :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** +- [x] Tag Colors + - [x] Built-in Color Palette **[v8.0.0]** + - [x] User-Defined Colors **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] Primary and Secondary Colors **[[v9.5.0](changelog.md#950-march-3rd-2025)]** +- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** + - [ ] Built-in Icon Packs (i.e. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] Tint Icons with Text Color :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** +- [x] [Category Property](tags.md#is-category) **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title + - [ ] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [x] Hidden Property **[[v9.5.7]](changelog.md#957-may-5th-2026)** + - [x] Built-in "Archived" tag has this property by default **[[v9.5.7]](changelog.md#957-may-5th-2026)** + - [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7]](changelog.md#957-may-5th-2026)** +- [ ] Tag Relationships + - [x] [Parent Tags](tags.md#parent-tags) ([Inheritance]() Relationship) **[v9.0.0]** + - [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]** +- [ ] Multiple Language Support :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.9.x]** +- [ ] Tag Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.8.x]** +- [ ] Tag Merging :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.x]** + +### :material-magnify: Search + +- [x] Tag Search **[v8.0.0]** +- [x] Filename Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] Glob Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** +- [x] Filetype Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] Search by Extension (e.g. ".jpg", ".png") **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] Search by media type (e.g. "image", "video", "document") **[[v9.5.0](changelog.md#950-march-3rd-2025)]** +- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [x] [Boolean Operators](search.md) **[[v9.5.0](changelog.md#950-march-3rd-2025)]** + - [x] `AND` Operator + - [x] `OR` Operator + - [x] `NOT` Operator + - [x] Parenthesis Grouping + - [x] Character Escaping +- [ ] `HAS` Operator (for [Component Tags](tags.md#component-tags)) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** +- [ ] Conditional Search :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]** + - [ ] Compare Dates :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Compare Durations :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Compare File Sizes :material-chevron-double-up:{ .priority-med title="Medium Priority" } + - [ ] Compare Dimensions :material-chevron-double-up:{ .priority-med title="Medium Priority" } +- [x] Smartcase Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]** +- [ ] Search Result Sorting + - [x] Sort by Filename **[[v9.5.2](changelog.md#952-march-31st-2025)]** + - [x] Sort by Date Entry Added to Library **[[v9.5.2](changelog.md#952-march-31st-2025)]** + - [ ] Sort by File Creation Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Sort by Date Taken (Photos) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [x] Random/Shuffle Sort +- [ ] OCR Search :material-chevron-up:{ .priority-low title="Low Priority" } +- [ ] Fuzzy Search :material-chevron-up:{ .priority-low title="Low Priority" } + +### :material-file-cog: Macros + +- [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** +- [ ] Versioning System :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** +- [ ] Triggers **[v9.7.x]** + - [ ] On File Added :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] On Library Refresh :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] [...] +- [ ] Actions **[v9.7.x]** + - [ ] Add Tag(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Add Field(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Set Field Content :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] [...] ### :material-table-arrow-right: Sharable Data Sharable TagStudio library data in the form of data packs (tags, colors, etc.) or other formats. Packs are intended as an easy way to import and export specific data between libraries and users, while export-only formats are intended to be imported by other programs. -- [ ] Color Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]** - - [ ] Importable - - [ ] Exportable - - [x] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [x] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Versioning System :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [ ] Tag Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.x]** - - [ ] Importable :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Exportable :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Versioning System :material-chevron-double-up:{ .priority-med title="Medium Priority" } -- [ ] Macro Sharing :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]** - - [ ] Importable :material-chevron-triple-up:{ .priority-high title="High Priority" } - - [ ] Exportable :material-chevron-triple-up:{ .priority-high title="High Priority" } -- [ ] Sharable Entry Data :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.x]** - - _Specifics of this are yet to be determined_ -- [ ] Export Library to Human Readable Format :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** - - Intended to give users more flexible options with their data if they wish to migrate away from TagStudio +- [ ] Color Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]** + - [ ] Importable + - [ ] Exportable + - [x] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [x] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Versioning System :material-chevron-double-up:{ .priority-med title="Medium Priority" } +- [ ] Tag Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]** + - [ ] Importable :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Exportable :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Versioning System :material-chevron-double-up:{ .priority-med title="Medium Priority" } +- [ ] Macro Sharing :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]** + - [ ] Importable :material-chevron-triple-up:{ .priority-high title="High Priority" } + - [ ] Exportable :material-chevron-triple-up:{ .priority-high title="High Priority" } +- [ ] Sharable Entry Data :material-chevron-up:{ .priority-low title="Low Priority" } + - _Specifics of this are yet to be determined_ +- [ ] Export Library to Human Readable Format :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]** + - Intended to give users more flexible options with their data if they wish to migrate away from TagStudio diff --git a/docs/search.md b/docs/search.md index 6d2007ed6..0f708cc43 100644 --- a/docs/search.md +++ b/docs/search.md @@ -1,7 +1,11 @@ --- +title: Searching icon: material/magnify --- + + + # :material-magnify: Searching TagStudio provides various methods to search your library, ranging from TagStudio data such as tags to inherent file data such as paths or media types. @@ -48,13 +52,13 @@ Sometimes search queries have ambiguous characters and need to be "escaped". Thi #### Valid Escaped Tag Searches -- "Tag Name With Spaces" -- Tag_Name_With_Spaces +- "Tag Name With Spaces" +- Tag_Name_With_Spaces #### Invalid Escaped Tag Searches -- Tag Name With Spaces - - Reason: Ambiguity between a tag named "Tag Name With Spaces" and four individual tags called "Tag", "Name", "With", "Spaces". +- Tag Name With Spaces + - Reason: Ambiguity between a tag named "Tag Name With Spaces" and four individual tags called "Tag", "Name", "With", "Spaces". ## Tags @@ -84,31 +88,31 @@ Optionally, you may use [glob]( + + # :material-sign-text: Style Guide ## Formatting Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._ -- Do your best to write clear, concise, and modular code. - - This should include making methods private by default (e.g. `__method()`) - - Methods should only be protected (e.g. `_method()`) or public (e.g. `method()`) when needed and warranted -- Keep a maximum column width of no more than **100** characters. -- Code comments should be used to help describe sections of code that can't speak for themselves. -- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add. - - If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;) -- Imports should be ordered alphabetically. -- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order). - - Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern. -- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check. -- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)! +- Do your best to write clear, concise, and modular code. + - This should include making methods private by default (e.g. `__method()`) + - Methods should only be protected (e.g. `_method()`) or public (e.g. `method()`) when needed and warranted +- Keep a maximum column width of no more than **100** characters. +- Code comments should be used to help describe sections of code that can't speak for themselves. +- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add. + - If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;) +- Imports should be ordered alphabetically. +- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order). + - Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern. +- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check. +- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)! + +### Modules & Implementations + +- **Do not** modify legacy library code in the `src/core/library/json/` directory +- Avoid direct calls to `os` + - Use `Pathlib` library instead of `os.path` + - Use `platform.system()` instead of `os.name` and `sys.platform` +- Don't prepend local imports with `tagstudio`, stick to `src` +- Use the `logger` system instead of `print` statements +- Avoid nested f-strings +- Use HTML-like tags inside Qt widgets over stylesheets where possible + +Final submitted code must **_NOT:_** + +- Contain superfluous or unnecessary logging statements +- Cause unreasonable slowdowns to the program outside of a progress-indicated task +- Cause undesirable visual glitches or artifacts on screen + +### Formatter Configs + +TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) file ([`.editorconfig`](https://github.com/TagStudioDev/TagStudio/blob/main/.editorconfig)) along with a [Prettier](https://prettier.io/) config file ([`.prettierrc.toml`](https://github.com/TagStudioDev/TagStudio/blob/main/.prettierrc.toml)) for formatting files other than .py files (Markdown, JSON, YAML, HTML, CSS, etc.). If editing these types of files it's recommended that you use a formatter that supports EditorConfig or has its settings matched to the EditorConfig and Prettier configs. Lastly, please pay attention to the `prettier-ignore` flags in present in some files if you are not using Prettier, as formatting these sections will break formatting used elsewhere such as the [MkDocs site](https://docs.tagstud.io/). ## Qt @@ -80,14 +105,14 @@ class MyCoolWidget(MyCoolWidgetView): Observe the following key aspects of this example: -- The Controller is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code -- The UI elements are in private variables - - This enforces that the controller shouldn't directly access UI elements - - Instead the view should provide a protected API (e.g. `_get_color()`) for things like setting/getting the value of a dropdown, etc. - - Instead of `_get_color()` there could also be a `_color` method marked with `@property` -- The callback methods are already defined as protected methods with NotImplementedErrors - - Defines the interface the callbacks - - Enforces that UI events be handled +- The Controller is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code +- The UI elements are in private variables + - This enforces that the controller shouldn't directly access UI elements + - Instead the view should provide a protected API (e.g. `_get_color()`) for things like setting/getting the value of a dropdown, etc. + - Instead of `_get_color()` there could also be a `_color` method marked with `@property` +- The callback methods are already defined as protected methods with NotImplementedErrors + - Defines the interface the callbacks + - Enforces that UI events be handled !!! tip diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index f1e72d908..33d74f03a 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,3 +1,7 @@ +/* + * SPDX-FileCopyrightText: (c) TagStudio Contributors + * SPDX-License-Identifier: GPL-3.0-only + */ /* Dark Theme */ [data-md-color-scheme="slate"] { --md-default-bg-color: #060617; @@ -58,11 +62,7 @@ /* Mobile Nav Header */ .md-nav__source { - background: linear-gradient( - 60deg, - rgb(205, 78, 255) 0%, - rgb(116, 123, 255) 100% - ); + background: linear-gradient(60deg, rgb(205, 78, 255) 0%, rgb(116, 123, 255) 100%); border-style: solid; border-width: 0 0 2px 0; border-color: #ffffff33; diff --git a/docs/stylesheets/home.css b/docs/stylesheets/home.css index 26e9c2d1c..a32f0a80c 100644 --- a/docs/stylesheets/home.css +++ b/docs/stylesheets/home.css @@ -1,3 +1,7 @@ +/* + * SPDX-FileCopyrightText: (c) TagStudio Contributors + * SPDX-License-Identifier: GPL-3.0-only + * */ h2 { margin: 1rem 0 0 0 !important; } diff --git a/docs/tags.md b/docs/tags.md index b8cb65e1b..56a30bdce 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -1,7 +1,11 @@ --- +title: Tags icon: material/tag-text --- + + + # :material-tag-text: Tags Tags are discrete objects that represent some attribute. This could be a person, place, object, concept, and more. Unlike most tagging systems, TagStudio tags are not solely represented by a line of text or a hashtag. Tags in TagStudio consist of several properties and relationships that give extra customization, searching power, and ease of tagging that cannot be achieved by string-based tags alone. TagStudio tags are designed to be as simple or as complex as you'd like, giving options to users of all skill levels and use cases. @@ -10,9 +14,9 @@ Tags are discrete objects that represent some attribute. This could be a person, TagStudio tags do not share the same naming limitations of many other tagging solutions. The key standouts of tag names in TagStudio are: -- Tag names do **NOT** have to be unique -- Tag names are **NOT** limited to specific characters -- Tags can have **aliases**, a.k.a. alternate names to go by +- Tag names do **NOT** have to be unique +- Tag names are **NOT** limited to specific characters +- Tags can have **aliases**, a.k.a. alternate names to go by ### Name diff --git a/docs/usage.md b/docs/usage.md index aa083e8c5..9f3a4b89b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,7 +1,11 @@ --- +title: Basic Usage icon: material/mouse --- + + + # :material-mouse: Basic Usage ## Creating/Opening a Library @@ -34,14 +38,14 @@ Hover over the field and click the pencil icon. From there, add or edit text in Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+T. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color. -- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_** -- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation. -- **Aliases** are alternate names for a tag. These let you search for terms other than the exact tag name in order to find the tag again. -- **Parent Tags** are tags in which this tag can substitute for in searches. In other words, tags under this section are parents of this tag. - - Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique. - - For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)". -- The **color** option lets you select an optional color palette to use for your tag. -- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel. +- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_** +- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation. +- **Aliases** are alternate names for a tag. These let you search for terms other than the exact tag name in order to find the tag again. +- **Parent Tags** are tags in which this tag can substitute for in searches. In other words, tags under this section are parents of this tag. + - Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique. + - For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)". +- The **color** option lets you select an optional color palette to use for your tag. +- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel. ### Tag Manager diff --git a/flake.nix b/flake.nix index c12b22b6e..ffde5e2ed 100644 --- a/flake.nix +++ b/flake.nix @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + { description = "TagStudio"; diff --git a/mkdocs.yml b/mkdocs.yml index aaf4b1063..7d3436a4d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json # MkDocs: https://www.mkdocs.org/ @@ -33,9 +36,9 @@ nav: - install.md - usage.md - Developing: - - developing.md - - contributing.md - - style.md + - developing.md + - contributing.md + - style.md - Help: - help/ffmpeg.md - Using Libraries: @@ -46,7 +49,7 @@ nav: - ignore.md - macros.md - Fields: - - fields.md + - fields.md - Tags: - tags.md - colors.md @@ -54,7 +57,7 @@ nav: - changelog.md - roadmap.md - Schema History: - - library-changes.md + - library-changes.md theme: name: material @@ -104,7 +107,6 @@ theme: upcoming: material/flask-outline markdown_extensions: - # Python Markdown - abbr - admonition @@ -114,6 +116,7 @@ markdown_extensions: - md_in_html - tables - toc: + title: Table of Contents permalink: true toc_depth: 3 @@ -131,6 +134,11 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.keys - pymdownx.mark + - pymdownx.magiclink: + repo_url_shorthand: True + provider: github + user: TagStudioDev + repo: TagStudio - pymdownx.smartsymbols - pymdownx.snippets - pymdownx.superfences: @@ -148,8 +156,9 @@ markdown_extensions: plugins: - search - tags - - social: # social embed cards - enabled: !ENV [CI, false] # enabled only when running in CI (eg GitHub Actions) + - typeset + - social: # social embed cards + enabled: !ENV [CI, false] # enabled only when running in CI (eg GitHub Actions) - redirects: redirect_maps: "develop.md": "developing.md" diff --git a/nix/package/default.nix b/nix/package/default.nix index 52438b426..b609e5f91 100644 --- a/nix/package/default.nix +++ b/nix/package/default.nix @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + { ffmpeg-headless, lib, @@ -80,6 +84,8 @@ python3Packages.buildPythonApplication { "py7zr" "pyside6" "rarfile" + "requests" + "semver" "structlog" "typing-extensions" ]; @@ -103,6 +109,8 @@ python3Packages.buildPythonApplication { pyside6 rarfile rawpy + requests + semver send2trash sqlalchemy srctools diff --git a/nix/package/pillow-jxl-plugin.nix b/nix/package/pillow-jxl-plugin.nix index 4df28aeda..4b8ecba58 100644 --- a/nix/package/pillow-jxl-plugin.nix +++ b/nix/package/pillow-jxl-plugin.nix @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + { buildPythonPackage, cmake, diff --git a/nix/package/pyexiv2.nix b/nix/package/pyexiv2.nix index 4761db496..a9cb9c418 100644 --- a/nix/package/pyexiv2.nix +++ b/nix/package/pyexiv2.nix @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + { autoPatchelfHook, buildPythonPackage, diff --git a/nix/shell.nix b/nix/shell.nix index 81965fc80..797f1fc63 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + { lib, pkgs, diff --git a/overrides/partials/header.html b/overrides/partials/header.html index 0db2a399c..e1ffcd5d4 100644 --- a/overrides/partials/header.html +++ b/overrides/partials/header.html @@ -1,24 +1,7 @@ - + + - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to - deal in the Software without restriction, including without limitation the - rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS - IN THE SOFTWARE. ---> {% set class = "md-header" %} diff --git a/overrides/partials/nav.html b/overrides/partials/nav.html index 119d738a1..432eb57fd 100644 --- a/overrides/partials/nav.html +++ b/overrides/partials/nav.html @@ -1,24 +1,7 @@ - + + - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to - deal in the Software without restriction, including without limitation the - rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS - IN THE SOFTWARE. ---> {% import "partials/nav-item.html" as item with context %} diff --git a/overrides/partials/toc-item.html b/overrides/partials/toc-item.html index a0922005a..8213616e5 100644 --- a/overrides/partials/toc-item.html +++ b/overrides/partials/toc-item.html @@ -1,32 +1,26 @@ - + + - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to - deal in the Software without restriction, including without limitation the - rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS - IN THE SOFTWARE. --->
  • - - + + + + + {% if toc_item.typeset %} + + {{ toc_item.typeset.title }} + + + + {% else %} {{ toc_item.title }} - - + {% endif %} + + {% if toc_item.children %} @@ -40,4 +34,4 @@ {% endif %} -
  • \ No newline at end of file + diff --git a/pyproject.toml b/pyproject.toml index cb095d849..d3dc8a40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -5,7 +9,7 @@ build-backend = "hatchling.build" [project] name = "TagStudio" description = "A User-Focused Photo & File Management System." -version = "9.5.6" +version = "9.5.7" license = "GPL-3.0-only" readme = "README.md" requires-python = ">=3.12,<3.13" @@ -24,7 +28,7 @@ dependencies = [ "pydub~=0.25", "PySide6==6.8.0.*", "rarfile==4.2", - "rawpy~=0.24", + "rawpy~=0.27", "Send2Trash~=1.8", "SQLAlchemy~=2.0", "srctools~=2.6", @@ -33,19 +37,22 @@ dependencies = [ "typing_extensions~=4.13", "ujson~=5.10", "wcmatch==10.*", + "requests~=2.31.0", + "semver~=3.0.4", ] [project.optional-dependencies] -dev = ["tagstudio[mkdocs,mypy,pre-commit,pyinstaller,pytest,ruff]"] -mkdocs = ["mkdocs-material[imaging]>=9.6.14", "mkdocs-redirects~=1.2"] -mypy = ["mypy==1.15.0", "mypy-extensions==1.*", "types-ujson~=5.10"] +dev = ["tagstudio[mkdocs,pyright,pre-commit,pyinstaller,pytest,ruff]"] +mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"] +pyright = ["pyright~=1.1.409"] pre-commit = ["pre-commit~=4.2"] pyinstaller = ["Pyinstaller~=6.13"] pytest = [ - "pytest==8.3.5", + "pytest==9.0.3", "pytest-cov==6.1.1", + "pytest-mock==3.15.1", "pytest-qt==4.4.0", - "syrupy==4.9.1", + "syrupy==5.1.0", ] ruff = ["ruff==0.11.8"] @@ -55,31 +62,6 @@ tagstudio = "tagstudio.main:main" [tool.hatch.build.targets.wheel] packages = ["src/tagstudio"] -[tool.mypy] -mypy_path = ["src/tagstudio"] -disable_error_code = [ - "annotation-unchecked", - "func-returns-value", - "import-untyped", -] -explicit_package_bases = true -ignore_missing_imports = true -implicit_optional = true -strict_optional = false -warn_unused_ignores = true -exclude = ["build", "dist"] - -[[tool.mypy.overrides]] -module = "tagstudio.qt.main_window" -ignore_errors = true - -[[tool.mypy.overrides]] -module = "tagstudio.qt.ui.home_ui" -ignore_errors = true - -[[tool.mypy.overrides]] -module = "tagstudio.core.ts_core" -ignore_errors = true [tool.pytest.ini_options] #addopts = "-m 'not qt'" @@ -92,17 +74,20 @@ ignore = [ "src/tagstudio/qt/previews/vendored/pydub/", ] include = ["src/tagstudio", "tests"] +# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md reportAny = false reportIgnoreCommentWithoutRule = false reportImplicitStringConcatenation = false +reportImportCycles = false reportMissingTypeArgument = false reportMissingTypeStubs = false # reportOptionalMemberAccess = false -reportUnannotatedClassAttribute = false reportUnknownArgumentType = false reportUnknownLambdaType = false reportUnknownMemberType = false reportUnusedCallResult = false +reportUnannotatedClassAttribute = false +reportUninitializedInstanceVariable = false [tool.ruff] exclude = ["home_ui.py", "resources.py", "resources_rc.py"] diff --git a/src/tagstudio/core/constants.py b/src/tagstudio/core/constants.py index f332c9ad2..4cd6f5829 100644 --- a/src/tagstudio/core/constants.py +++ b/src/tagstudio/core/constants.py @@ -1,9 +1,10 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only -VERSION: str = "9.5.6" # Major.Minor.Patch + +VERSION: str = "9.5.7" # Major.Minor.Patch VERSION_BRANCH: str = "" # Usually "" or "Pre-Release" +GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest" # The folder & file names where TagStudio keeps its data relative to a library. TS_FOLDER_NAME: str = ".TagStudio" diff --git a/src/tagstudio/core/driver.py b/src/tagstudio/core/driver.py index 4343054df..cf50c45ee 100644 --- a/src/tagstudio/core/driver.py +++ b/src/tagstudio/core/driver.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + from pathlib import Path import structlog diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index 4284fe407..f821e1038 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -1,10 +1,8 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import enum -from typing import Any -from uuid import uuid4 class SettingItems(str, enum.Enum): @@ -57,30 +55,3 @@ class MacroID(enum.Enum): BUILD_URL = "build_url" MATCH = "match" CLEAN_URL = "clean_url" - - -class DefaultEnum(enum.Enum): - """Allow saving multiple identical values in property called .default.""" - - default: Any - - def __new__(cls, value): - # Create the enum instance - obj = object.__new__(cls) - # make value random - obj._value_ = uuid4() - # assign the actual value into .default property - obj.default = value - return obj - - @property - def value(self): - raise AttributeError("access the value via .default property instead") - - -# TODO: Remove DefaultEnum and LibraryPrefs classes once remaining values are removed. -class LibraryPrefs(DefaultEnum): - """Library preferences with default value accessible via .default property.""" - - IS_EXCLUDE_LIST = True - EXTENSION_LIST = [".json", ".xmp", ".aae"] diff --git a/src/tagstudio/core/exceptions.py b/src/tagstudio/core/exceptions.py index 10bec533e..f3ab828c5 100644 --- a/src/tagstudio/core/exceptions.py +++ b/src/tagstudio/core/exceptions.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only class NoRendererError(Exception): ... diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 1e2080249..efe03aa29 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from sqlalchemy import text @@ -8,10 +7,9 @@ SQL_FILENAME: str = "ts_library.sqlite" JSON_FILENAME: str = "ts_library.json" -DB_VERSION_LEGACY_KEY: str = "DB_VERSION" DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 103 +DB_VERSION: int = 201 TAG_CHILDREN_QUERY = text(""" WITH RECURSIVE ChildTags AS ( diff --git a/src/tagstudio/core/library/alchemy/db.py b/src/tagstudio/core/library/alchemy/db.py index 8e3e6a618..de119d0a5 100644 --- a/src/tagstudio/core/library/alchemy/db.py +++ b/src/tagstudio/core/library/alchemy/db.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path @@ -66,8 +65,3 @@ def make_tables(engine: Engine) -> None: except OperationalError as e: logger.error("Could not initialize built-in tags", error=e) conn.rollback() - - -def drop_tables(engine: Engine) -> None: - logger.info("dropping db tables") - Base.metadata.drop_all(engine) diff --git a/src/tagstudio/core/library/alchemy/default_color_groups.py b/src/tagstudio/core/library/alchemy/default_color_groups.py index 9193fc09d..4af29595a 100644 --- a/src/tagstudio/core/library/alchemy/default_color_groups.py +++ b/src/tagstudio/core/library/alchemy/default_color_groups.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import structlog diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 3523b810c..349d0e9e2 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + import enum import random -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from pathlib import Path import structlog @@ -78,8 +82,9 @@ class BrowsingState: """Represent a state of the Library grid view.""" page_index: int = 0 + page_positions: dict[int, int] = field(default_factory=dict) sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED - ascending: bool = True + ascending: bool = False random_seed: float = 0 show_hidden_entries: bool = False @@ -151,11 +156,3 @@ def with_search_query(self, search_query: str) -> "BrowsingState": def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState": return replace(self, show_hidden_entries=show_hidden_entries) - - -class FieldTypeEnum(enum.Enum): - TEXT_LINE = "Text Line" - TEXT_BOX = "Text Box" - 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..1b5e59288 100644 --- a/src/tagstudio/core/library/alchemy/fields.py +++ b/src/tagstudio/core/library/alchemy/fields.py @@ -1,22 +1,18 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from __future__ import annotations -from dataclasses import dataclass, field -from enum import Enum from typing import TYPE_CHECKING, Any, override from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship from tagstudio.core.library.alchemy.db import Base -from tagstudio.core.library.alchemy.enums import FieldTypeEnum if TYPE_CHECKING: - from tagstudio.core.library.alchemy.models import Entry, ValueType + from tagstudio.core.library.alchemy.models import Entry class BaseField(Base): @@ -24,121 +20,143 @@ class BaseField(Base): @declared_attr def id(self) -> Mapped[int]: - return mapped_column(primary_key=True, autoincrement=True) - - @declared_attr - def type_key(self) -> Mapped[str]: - return mapped_column(ForeignKey("value_type.key")) + return mapped_column(primary_key=True, autoincrement=True, sort_order=1) @declared_attr - def type(self) -> Mapped[ValueType]: - return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore # pyright: ignore[reportArgumentType] + def name(self) -> Mapped[str]: + return mapped_column(nullable=False, default="", sort_order=2) @declared_attr def entry_id(self) -> Mapped[int]: - return mapped_column(ForeignKey("entries.id")) + return mapped_column(ForeignKey("entries.id"), sort_order=3) @declared_attr def entry(self) -> Mapped[Entry]: - return relationship(foreign_keys=[self.entry_id]) # type: ignore # pyright: ignore[reportArgumentType] + return relationship(foreign_keys=[self.entry_id]) # pyright: ignore[reportArgumentType] - @declared_attr - def position(self) -> Mapped[int]: - return mapped_column(default=0) + @property + def class_name(self) -> str: + return self.__class__.__name__ - @override - def __hash__(self): - return hash(self.__key()) - - def __key(self): # pyright: ignore[reportUnknownParameterType] - raise NotImplementedError + def clone_with_entry_id(self, entry_id: int) -> BaseField: # pyright: ignore + raise NotImplementedError() value: Any # pyright: ignore -class BooleanField(BaseField): - __tablename__ = "boolean_fields" +class TextField(BaseField): + __tablename__ = "text_fields" + + value: Mapped[str | None] = mapped_column(sort_order=4) + is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False) - value: Mapped[bool] + @override + def __eq__(self, other: object) -> bool: + if not isinstance(other, TextField): + return False - def __key(self): - return (self.type, self.value) + return (self.name, self.value, self.is_multiline) == ( + other.name, + other.value, + other.is_multiline, + ) @override - def __eq__(self, value: object) -> bool: - if isinstance(value, BooleanField): - return self.__key() == value.__key() - raise NotImplementedError + def __hash__(self) -> int: + return hash((self.name, self.value, self.is_multiline)) + @override + def clone_with_entry_id(self, entry_id: int) -> TextField: + return TextField( + name=self.name, entry_id=entry_id, value=self.value, is_multiline=self.is_multiline + ) -class TextField(BaseField): - __tablename__ = "text_fields" - value: Mapped[str | None] +class DatetimeField(BaseField): + __tablename__ = "datetime_fields" - def __key(self) -> tuple[ValueType, str | None]: - return self.type, self.value + value: Mapped[str | None] = mapped_column(sort_order=4) @override - def __eq__(self, value: object) -> bool: - if isinstance(value, TextField): - return self.__key() == value.__key() - elif isinstance(value, DatetimeField): + def __eq__(self, other: object) -> bool: + if not isinstance(other, DatetimeField): return False - raise NotImplementedError + return (self.name, self.value) == (other.name, other.value) -class DatetimeField(BaseField): - __tablename__ = "datetime_fields" + @override + def __hash__(self) -> int: + return hash((self.name, self.value)) + + @override + def clone_with_entry_id(self, entry_id: int) -> DatetimeField: + return DatetimeField(name=self.name, entry_id=entry_id, value=self.value) + + +class BaseFieldTemplate(Base): + __abstract__ = True + + @declared_attr + def id(self) -> Mapped[int]: + return mapped_column(primary_key=True, autoincrement=True) + + @declared_attr + def name(self) -> Mapped[str]: + return mapped_column(nullable=False, default="") + + @property + def class_name(self) -> str: + return self.__class__.__name__ + + def to_field(self, value: Any | None = None) -> BaseField: # pyright: ignore + raise NotImplementedError() + + +class TextFieldTemplate(BaseFieldTemplate): + __tablename__ = "text_field_templates" + is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False) + + @override + def to_field(self, value: str | None = None) -> TextField: + return TextField(name=self.name, value=value, is_multiline=self.is_multiline) - value: Mapped[str | None] - def __key(self): - return (self.type, self.value) +class DatetimeFieldTemplate(BaseFieldTemplate): + __tablename__ = "datetime_field_templates" @override - def __eq__(self, value: object) -> bool: - if isinstance(value, DatetimeField): - return self.__key() == value.__key() - raise NotImplementedError - - -@dataclass -class DefaultField: - id: int - name: str - type: FieldTypeEnum - is_default: bool = field(default=False) - - -class FieldID(Enum): - """Only for bootstrapping content of DB table.""" - - 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) - 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) - DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME) - DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME) - DATE_MODIFIED = DefaultField(id=12, name="Date Modified", type=FieldTypeEnum.DATETIME) - DATE_TAKEN = DefaultField(id=13, name="Date Taken", type=FieldTypeEnum.DATETIME) - DATE_PUBLISHED = DefaultField(id=14, name="Date Published", type=FieldTypeEnum.DATETIME) - # ARCHIVED = DefaultField(id=15, name="Archived", type=CheckboxField.checkbox) - # FAVORITE = DefaultField(id=16, name="Favorite", type=CheckboxField.checkbox) - BOOK = DefaultField(id=17, name="Book", type=FieldTypeEnum.TEXT_LINE) - 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) - 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) - ANTHOLOGY = DefaultField(id=25, name="Anthology", type=FieldTypeEnum.TEXT_LINE) - MAGAZINE = DefaultField(id=26, name="Magazine", type=FieldTypeEnum.TEXT_LINE) - PUBLISHER = DefaultField(id=27, name="Publisher", type=FieldTypeEnum.TEXT_LINE) - GUEST_ARTIST = DefaultField(id=28, name="Guest Artist", type=FieldTypeEnum.TEXT_LINE) - COMPOSER = DefaultField(id=29, name="Composer", type=FieldTypeEnum.TEXT_LINE) - COMMENTS = DefaultField(id=30, name="Comments", type=FieldTypeEnum.TEXT_LINE) + def to_field(self, value: str | None = None) -> DatetimeField: + return DatetimeField(name=self.name, value=value) + + +# Used for migrating legacy libraries. +# Legacy JSON libraries ( str: def get_default_tags() -> tuple[Tag, ...]: + """Return the built-in tags for a new TagStudio library.""" meta_tag = Tag( id=TAG_META, name="Meta Tags", @@ -170,6 +171,20 @@ def get_default_tags() -> tuple[Tag, ...]: return archive_tag, favorite_tag, meta_tag +def get_default_field_templates() -> tuple[BaseFieldTemplate, ...]: + """Return the default field templates for a new TagStudio library.""" + title = TextFieldTemplate(name="Title") + author = TextFieldTemplate(name="Author") + artist = TextFieldTemplate(name="Artist") + url = TextFieldTemplate(name="URL") + description = TextFieldTemplate(name="Description", is_multiline=True) + notes = TextFieldTemplate(name="Notes", is_multiline=True) + comments = TextFieldTemplate(name="Comments", is_multiline=True) + date = DatetimeFieldTemplate(name="Date") + + return title, author, artist, url, description, notes, comments, date + + # The difference in the number of default JSON tags vs default tags in the current version. DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE]) @@ -217,7 +232,6 @@ class Library: """Class for the Library object, and all CRUD operations made upon it.""" library_dir: Path | None = None - storage_path: Path | str | None = None engine: Engine | None = None folder: Folder | None = None included_files: set[Path] = set() @@ -232,7 +246,6 @@ def close(self): if self.engine: self.engine.dispose() self.library_dir = None - self.storage_path = None self.folder = None self.included_files = set() @@ -300,38 +313,56 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): path=entry.path / entry.filename, folder=folder, fields=[], - id=entry.id + 1, # JSON IDs start at 0 instead of 1 + id=entry.id + 1, # NOTE: JSON IDs start at 0 instead of 1 date_added=datetime.now(), ) for entry in json_lib.entries ] ) + for entry in json_lib.entries: for field in entry.fields: # pyright: ignore[reportUnknownVariableType] - for k, v in field.items(): # pyright: ignore[reportUnknownVariableType] + for legacy_field_id, value in field.items(): # pyright: ignore[reportUnknownVariableType] # Old tag fields get added as tags - if k in LEGACY_TAG_FIELD_IDS: - self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=v) + if legacy_field_id in LEGACY_TAG_FIELD_IDS: + self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=value) else: - self.add_field_to_entry( - entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1 - field_id=self.get_field_name_from_id(k), - value=v, - ) + try: + # NOTE: JSON IDs start at 0 instead of 1 + field_info = LEGACY_FIELD_MAP[legacy_field_id] + if field_info["type"] == TextField: + text_field = TextField( + name=str(field_info["name"]), + value=value, + is_multiline=bool(field_info["is_multiline"]), + ) + self.add_field_to_entries( + entry_ids=(entry.id + 1), field=text_field + ) + elif field_info["type"] == DatetimeField: + datetime_field = DatetimeField( + name=str(field_info["name"]), value=value + ) + self.add_field_to_entries( + entry_ids=(entry.id + 1), field=datetime_field + ) + except Exception as e: + logger.error( + "[Library][JSON Migration] Error reading field", + error=e, + entry_id=entry.id + 1, + legacy_field_id=legacy_field_id, + value=value, + ) - # Preferences - self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list]) - self.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, json_lib.is_exclude_list) + # extension include/exclude list + (unwrap(self.library_dir) / TS_FOLDER_NAME / IGNORE_NAME).write_text( + migrate_ext_list([x.strip(".") for x in json_lib.ext_list], json_lib.is_exclude_list) + ) end_time = time.time() logger.info(f"Library Converted! ({format_timespan(end_time - start_time)})") - def get_field_name_from_id(self, field_id: int) -> FieldID | None: - for f in FieldID: - if field_id == f.value.id: - return f - return None - def tag_display_name(self, tag: Tag | None) -> str: if not tag: return "" @@ -348,33 +379,36 @@ def tag_display_name(self, tag: Tag | None) -> str: else: return tag.name - def open_library( - self, library_dir: Path, storage_path: Path | str | None = None - ) -> LibraryStatus: - is_new: bool = True - if storage_path == ":memory:": - self.storage_path = storage_path - is_new = True - return self.open_sqlite_library(library_dir, is_new) - else: - self.storage_path = library_dir / TS_FOLDER_NAME / SQL_FILENAME - assert isinstance(self.storage_path, Path) - if self.verify_ts_folder(library_dir) and (is_new := not self.storage_path.exists()): - json_path = library_dir / TS_FOLDER_NAME / JSON_FILENAME - if json_path.exists(): - return LibraryStatus( - success=False, - library_path=library_dir, - message="[JSON] Legacy v9.4 library requires conversion to v9.5+", - json_migration_req=True, - ) + def open_library(self, library_dir: Path, in_memory: bool = False) -> LibraryStatus: + """Wrapper for open_sqlite_library. - return self.open_sqlite_library(library_dir, is_new) + Handles in-memory storage and checks whether a JSON-migration is necessary. + """ + assert isinstance(library_dir, Path) + + if in_memory: + return self.open_sqlite_library(library_dir, is_new=True, storage_path=":memory:") + + is_new = True + sql_path = library_dir / TS_FOLDER_NAME / SQL_FILENAME + if self.verify_ts_folder(library_dir) and (is_new := not sql_path.exists()): + json_path = library_dir / TS_FOLDER_NAME / JSON_FILENAME + if json_path.exists(): + return LibraryStatus( + success=False, + library_path=library_dir, + message="[JSON] Legacy v9.4 library requires conversion to v9.5+", + json_migration_req=True, + ) - def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: + return self.open_sqlite_library(library_dir, is_new, str(sql_path)) + + def open_sqlite_library( + self, library_dir: Path, is_new: bool, storage_path: str + ) -> LibraryStatus: connection_string = URL.create( drivername="sqlite", - database=str(self.storage_path), + database=storage_path, ) # NOTE: File-based databases should use NullPool to create new DB connection in order to # keep connections on separate threads, which prevents the DB files from being locked @@ -383,8 +417,9 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: # More info can be found on the SQLAlchemy docs: # https://docs.sqlalchemy.org/en/20/changelog/migration_07.html # Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases - poolclass = None if self.storage_path == ":memory:" else NullPool + poolclass = None if storage_path == ":memory:" else NullPool loaded_db_version: int = 0 + initial_db_version: int = DB_VERSION logger.info( "[Library] Opening SQLite Library", @@ -396,6 +431,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: # Don't check DB version when creating new library if not is_new: loaded_db_version = self.get_version(DB_VERSION_CURRENT_KEY) + initial_db_version = self.get_version(DB_VERSION_INITIAL_KEY) # ======================== Library Database Version Checking ======================= # DB_VERSION 6 is the first supported SQLite DB version. @@ -418,11 +454,11 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: ), ) - logger.info(f"[Library] DB_VERSION: {loaded_db_version}") + logger.info(f"[Library] Library DB version: {loaded_db_version}") make_tables(self.engine) - # Add default tag color namespaces. if is_new: + # Add default tag color namespaces. namespaces = default_color_groups.namespaces() try: session.add_all(namespaces) @@ -431,8 +467,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: logger.error("[Library] Couldn't add default tag color namespaces", error=e) session.rollback() - # Add default tag colors. - if is_new: + # Add default tag colors. tag_colors: list[TagColorGroup] = default_color_groups.standard() tag_colors += default_color_groups.pastels() tag_colors += default_color_groups.shades() @@ -447,8 +482,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: logger.error("[Library] Couldn't add default tag colors", error=e) session.rollback() - # Add default tags. - if is_new: + # Add default tags. tags = get_default_tags() try: session.add_all(tags) @@ -456,17 +490,20 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: except IntegrityError: session.rollback() + # Add default field templates + if is_new: + for template in get_default_field_templates(): + try: + session.add(template) + session.commit() + except IntegrityError: + logger.info( + "[Library] FieldTemplate already exists", field_template=template + ) + session.rollback() + # Ensure version rows are present with catch_warnings(record=True): - # NOTE: The "Preferences" table is depreciated and will be removed in the future. - # The DB_VERSION is still being set to it in order to remain backwards-compatible - # with existing TagStudio versions until it is removed. - try: - session.add(Preferences(key=DB_VERSION_LEGACY_KEY, value=DB_VERSION)) - session.commit() - except IntegrityError: - session.rollback() - try: initial = DB_VERSION if is_new else 100 session.add(Version(key=DB_VERSION_INITIAL_KEY, value=initial)) @@ -480,31 +517,6 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: except IntegrityError: session.rollback() - # TODO: Remove this "Preferences" system. - for pref in LibraryPrefs: - with catch_warnings(record=True): - try: - session.add(Preferences(key=pref.name, value=pref.default)) - session.commit() - except IntegrityError: - session.rollback() - - for field in FieldID: - try: - session.add( - ValueType( - key=field.name, - name=field.value.name, - type=field.value.type, - position=field.value.id, - is_default=field.value.is_default, - ) - ) - session.commit() - except IntegrityError: - logger.debug("ValueType already exists", field=field) - session.rollback() - # check if folder matching current path exists already self.folder = session.scalar(select(Folder).where(Folder.path == library_dir)) if not self.folder: @@ -529,60 +541,71 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: # Apply any post-SQL migration patches. if not is_new: + assert loaded_db_version >= 6 + # save backup if patches will be applied if loaded_db_version < DB_VERSION: self.library_dir = library_dir self.save_library_backup_to_disk() self.library_dir = None - # NOTE: Depending on the data, some data and schema changes need to be applied in - # different orders. This chain of methods can likely be cleaned up and/or moved. + # migrate DB step by step from one version to the next + if loaded_db_version < 7: + # changes: value_type, tags + self.__apply_db7_migration(session) if loaded_db_version < 8: - self.__apply_db8_schema_changes(session) - if loaded_db_version < 9: - self.__apply_db9_schema_changes(session) - if loaded_db_version < 103: - self.__apply_db103_schema_changes(session) - if loaded_db_version == 6: - self.__apply_repairs_for_db6(session) - - if loaded_db_version >= 6 and loaded_db_version < 8: - self.__apply_db8_default_data(session) + # changes: tag_colors + self.__apply_db8_migration(session) if loaded_db_version < 9: - self.__apply_db9_filename_population(session) + # changes: entries + self.__apply_db9_migration(session) if loaded_db_version < 100: - self.__apply_db100_parent_repairs(session) + # changes: tag_parents + self.__apply_db100_migration(session) if loaded_db_version < 102: - self.__apply_db102_repairs(session) + # changes: tag_parents + self.__apply_db102_migration(session) if loaded_db_version < 103: - self.__apply_db103_default_data(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) + # changes: tags + self.__apply_db103_migration(session) + if loaded_db_version < 104: + # changes: deletes preferences + self.__apply_db104_migrations(session, library_dir) + if loaded_db_version < 200: + self.__apply_db200_migrations(session) + # changes: field tables + if initial_db_version < 200 and loaded_db_version < 201: + self.__apply_db201_migrations(session) + + session.execute( + text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)") + ) + session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)" + ) + ) + session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)" + ) + ) # Update DB_VERSION if loaded_db_version < DB_VERSION: + logger.info(f"[Library] Library migrated to DB version {DB_VERSION}") self.set_version(DB_VERSION_CURRENT_KEY, DB_VERSION) # everything is fine, set the library path self.library_dir = library_dir return LibraryStatus(success=True, library_path=library_dir) - def __apply_repairs_for_db6(self, session: Session): - """Apply database repairs introduced in DB_VERSION 7.""" + def __apply_db7_migration(self, session: Session): + """Migrate DB from DB_VERSION 6 to 7.""" logger.info("[Library][Migration] Applying patches to DB_VERSION: 6 library...") with session: - # Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key. - desc_stmt = ( - update(ValueType) - .where(ValueType.key == FieldID.DESCRIPTION.name) - .values(type=FieldTypeEnum.TEXT_BOX.name) - ) - session.execute(desc_stmt) - session.flush() - # Repair tags that may have a disambiguation_id pointing towards a deleted tag. - all_tag_ids: set[int] = {tag.id for tag in self.tags} + all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all() disam_stmt = ( update(Tag) .where(Tag.disambiguation_id.not_in(all_tag_ids)) @@ -591,9 +614,8 @@ def __apply_repairs_for_db6(self, session: Session): session.execute(disam_stmt) session.commit() - def __apply_db8_schema_changes(self, session: Session): - """Apply database schema changes introduced in DB_VERSION 8.""" - # TODO: Use Alembic for this part instead + def __apply_db8_migration(self, session: Session): + """Migrate DB from DB_VERSION 7 to 8.""" # Add the missing color_border column to the TagColorGroups table. color_border_stmt = text( "ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL" @@ -609,8 +631,7 @@ def __apply_db8_schema_changes(self, session: Session): ) session.rollback() - def __apply_db8_default_data(self, session: Session): - """Apply default data changes introduced in DB_VERSION 8.""" + # collect new default tag colors tag_colors: list[TagColorGroup] = default_color_groups.standard() tag_colors += default_color_groups.pastels() tag_colors += default_color_groups.shades() @@ -659,8 +680,9 @@ def __apply_db8_default_data(self, session: Session): ) session.rollback() - def __apply_db9_schema_changes(self, session: Session): - """Apply database schema changes introduced in DB_VERSION 9.""" + def __apply_db9_migration(self, session: Session): + """Migrate DB from DB_VERSION 8 to 9.""" + # Apply database schema changes add_filename_column = text( "ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''" ) @@ -675,15 +697,14 @@ def __apply_db9_schema_changes(self, session: Session): ) session.rollback() - def __apply_db9_filename_population(self, session: Session): - """Populate the filename column introduced in DB_VERSION 9.""" + # Populate the new filename column. for entry in self.all_entries(): session.merge(entry).filename = entry.path.name session.commit() logger.info("[Library][Migration] Populated filename column in entries table") - def __apply_db100_parent_repairs(self, session: Session): - """Swap the child_id and parent_id values in the TagParent table.""" + def __apply_db100_migration(self, session: Session): + """Migrate DB to DB_VERSION 100.""" with session: # Repair parent-child tag relationships that are the wrong way around. stmt = update(TagParent).values( @@ -694,17 +715,18 @@ def __apply_db100_parent_repairs(self, session: Session): session.commit() logger.info("[Library][Migration] Refactored TagParent table") - def __apply_db102_repairs(self, session: Session): - """Repair tag_parents rows with references to deleted tags.""" + def __apply_db102_migration(self, session: Session): + """Migrate DB to DB_VERSION 102.""" with session: - all_tag_ids: list[int] = [t.id for t in self.tags] + all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all() stmt = delete(TagParent).where(TagParent.parent_id.not_in(all_tag_ids)) session.execute(stmt) session.commit() logger.info("[Library][Migration] Verified TagParent table data") - def __apply_db103_schema_changes(self, session: Session): - """Apply database schema changes introduced in DB_VERSION 103.""" + def __apply_db103_migration(self, session: Session): + """Migrate DB from DB_VERSION 102 to 103.""" + # add the new hidden column for tags add_is_hidden_column = text( "ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0" ) @@ -719,13 +741,11 @@ def __apply_db103_schema_changes(self, session: Session): ) session.rollback() - def __apply_db103_default_data(self, session: Session): - """Apply default data changes introduced in DB_VERSION 103.""" + # mark the "Archived" tag as hidden try: session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True}) session.commit() logger.info("[Library][Migration] Updated archived tag to be hidden") - session.commit() except Exception as e: logger.error( "[Library][Migration] Could not update archived tag to be hidden!", @@ -733,44 +753,175 @@ def __apply_db103_default_data(self, session: Session): ) 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(): - return + def __apply_db104_migrations(self, session: Session, library_dir: Path): + """Migrate DB from DB_VERSION 103 to 104.""" + # Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist + self.__migrate_sql_to_ts_ignore(library_dir) + session.execute(text("DROP TABLE preferences")) + session.commit() - # Create blank '.ts_ignore' file - ts_ignore_template = ( - Path(__file__).parents[3] / "resources/templates/ts_ignore_template_blank.txt" - ) + def __migrate_sql_to_ts_ignore(self, library_dir: Path): + # Do not continue if existing '.ts_ignore' file is found ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME - try: - shutil.copy2(ts_ignore_template, ts_ignore) - except Exception as e: - logger.error("[ERROR][Library] Could not generate '.ts_ignore' file!", error=e) + if Path(ts_ignore).exists(): + return # Load legacy extension data - extensions: list[str] = self.prefs(LibraryPrefs.EXTENSION_LIST) # pyright: ignore - is_exclude_list: bool = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST) # pyright: ignore - - # Copy extensions to '.ts_ignore' file - if ts_ignore.exists(): - with open(ts_ignore, "a") as f: - prefix = "" - if not is_exclude_list: - prefix = "!" - f.write("*\n") - f.writelines([f"{prefix}*.{x.lstrip('.')}\n" for x in extensions]) + with Session(self.engine) as session: + extensions: list[str] = unwrap( + session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'")) + ) + is_exclude_list: bool = unwrap( + session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'")) + ) + + with open(ts_ignore, "w") as f: + f.write(migrate_ext_list(extensions, is_exclude_list)) + + def __apply_db200_migrations(self, session: Session): + """Migrate DB to DB_VERSION 200.""" + with session: + # Drop unused 'boolean_fields' and 'value_type' tables + logger.info( + "[Library][Migration][200] Dropping boolean_fields and value_type tables..." + ) + session.execute(text("DROP TABLE boolean_fields")) + session.execute(text("DROP TABLE value_type")) + + # Add 'name' column to text_fields and datetime_fields tables + logger.info("[Library][Migration][200] Adding name columns to field tables...") + stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""') + session.execute(stmt) + stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""') + session.execute(stmt) + + # Drop unnecessary 'position' columns + logger.info("[Library][Migration][200] Dropping position columns to field tables...") + session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position")) + session.execute(text("ALTER TABLE text_fields DROP COLUMN position")) + + # Add 'is_multiline' column to text_fields table + logger.info("[Library][Migration][200] Adding is_multiline column to text_fields...") + stmt = text( + "ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0" + ) + session.execute(stmt) + session.flush() + + # Move values from old `type_key` columns into new `name` columns + logger.info("[Library][Migration][200] Moving values from type_key columns to name...") + session.execute(text("UPDATE text_fields SET name = type_key")) + session.execute(text("UPDATE datetime_fields SET name = type_key")) + session.flush() + + # Change `name` values to title case + logger.info("[Library][Migration][200] Normalizing TextField names...") + for text_field in session.execute(select(TextField)).scalars(): + # NOTE: The only exception to the "Title Case" conversion is the "URL" field. + text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ") + logger.info("[Library][Migration][200] Normalizing DatetimeField names...") + for datetime_field in session.execute(select(DatetimeField)).scalars(): + datetime_field.name = datetime_field.name.title().replace("_", " ") + session.flush() + + # Add correct `is_multiline` values to text_fields table + logger.info("[Library][Migration][200] Updating is_multiline for legacy TEXT_BOXes...") + text_boxes = [ + x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True + ] + update_stmt = ( + update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True) + ) + session.execute(update_stmt) + session.flush() + + # Repair legacy "Description" fields to use is_multiline = True + logger.info("[Library][Migration][200] Repairing legacy Description fields...") + desc_stmt = ( + update(TextField) + .where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712 + .values(is_multiline=True) + ) + session.execute(desc_stmt) + + # Repair legacy "Comments" fields to use is_multiline = True + logger.info("[Library][Migration][200] Repairing legacy Comment fields...") + comm_stmt = ( + update(TextField) + .where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712 + .values(is_multiline=True) + ) + session.execute(comm_stmt) + + # Add default field templates + logger.info("[Library][Migration][200] Adding default field templates...") + for template in get_default_field_templates(): + try: + session.add(template) + session.flush() + except IntegrityError: + logger.error("[Library] FieldTemplate already exists", field_template=template) + session.rollback() + + session.commit() + + def __apply_db201_migrations(self, session: Session): + """Migrate DB to DB_VERSION 201.""" + with session: + create_text_fields_table = text(""" + CREATE TABLE text_fields_new ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL, + entry_id INTEGER NOT NULL, + value VARCHAR, + is_multiline BOOLEAN NOT NULL, + FOREIGN KEY(entry_id) REFERENCES entries (id) + ) + """) + create_datetime_fields_table = text(""" + CREATE TABLE datetime_fields_new ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL, + entry_id INTEGER NOT NULL, + value VARCHAR, + FOREIGN KEY(entry_id) REFERENCES entries (id) + ) + """) + + logger.info("[Library][Migration][201] Dropping type_key from text_fields table...") + session.execute(create_text_fields_table) + session.flush() + session.execute( + text(""" + INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline) + SELECT id, name, entry_id, value, is_multiline + FROM text_fields + """) + ) + session.execute(text("DROP TABLE text_fields")) + session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields")) + + logger.info("[Library][Migration][201] Dropping type_key from datetime_fields table...") + session.execute(create_datetime_fields_table) + session.flush() + session.execute( + text(""" + INSERT INTO datetime_fields_new (id, name, entry_id, value) + SELECT id, name, entry_id, value + FROM datetime_fields + """) + ) + session.execute(text("DROP TABLE datetime_fields")) + session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields")) + + session.commit() @property - def default_fields(self) -> list[BaseField]: + def field_templates(self) -> Sequence[BaseFieldTemplate]: with Session(self.engine) as session: - types = session.scalars( - select(ValueType).where( - # check if field is default - ValueType.is_default.is_(True) - ) - ) - return [x.as_field for x in types] + text_templates = list(session.scalars(select(TextFieldTemplate))) + datetime_templates = list(session.scalars(select(DatetimeFieldTemplate))) + return text_templates + datetime_templates def get_entry(self, entry_id: int) -> Entry | None: """Load entry without joins.""" @@ -859,7 +1010,7 @@ def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]: ) statement = statement.distinct() entries: ScalarResult[Entry] | list[Entry] = session.execute(statement).scalars() - entries = entries.unique() # type: ignore + entries = entries.unique() entry_order_dict = {e_id: order for order, e_id in enumerate(entry_ids)} entries = sorted(entries, key=lambda e: entry_order_dict[e.id]) @@ -1001,8 +1152,8 @@ def remove_entries(self, entry_ids: list[int]) -> None: session.query(Entry).where(Entry.id.in_(sub_list)).delete() session.commit() - def has_path_entry(self, path: Path) -> bool: - """Check if item with given path is in library already.""" + def has_entry_with_path(self, path: Path) -> bool: + """Check if an entry with this path is in the library.""" with Session(self.engine) as session: return session.query(exists().where(Entry.path == path)).scalar() @@ -1095,62 +1246,78 @@ def search_library( return res - def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]: + def search_tags(self, name: str | None, limit: int = 100) -> tuple[list[Tag], list[Tag]]: """Return a list of Tag records matching the query.""" + if limit <= 0: + limit = sys.maxsize + + name = name or "" + name = name.lower() + + def sort_key(text: str): + priority = text.startswith(name) + p_ordering = len(text) if priority else sys.maxsize + return (not priority, p_ordering, text) + with Session(self.engine) as session: - query = select(Tag).outerjoin(TagAlias).order_by(func.lower(Tag.name)) - query = query.options( - selectinload(Tag.parent_tags), - selectinload(Tag.aliases), - ) - if limit > 0: - query = query.limit(limit) + query = select(Tag.id, Tag.name) + + if limit > 0 and not name: + query = query.order_by(Tag.name).limit(limit) if name: query = query.where( or_( Tag.name.icontains(name), Tag.shorthand.icontains(name), - TagAlias.name.icontains(name), ) ) - direct_tags = set(session.scalars(query)) - ancestor_tag_ids: list[Tag] = [] - for tag in direct_tags: - ancestor_tag_ids.extend( - list(session.scalars(TAG_CHILDREN_QUERY, {"tag_id": tag.id})) - ) + tags = list(session.execute(query)) - ancestor_tags = session.scalars( - select(Tag) - .where(Tag.id.in_(ancestor_tag_ids)) - .options(selectinload(Tag.parent_tags), selectinload(Tag.aliases)) - ) + if name: + query = select(TagAlias.tag_id, TagAlias.name).where(TagAlias.name.icontains(name)) + tags.extend(session.execute(query)) - res = [ - direct_tags, - {at for at in ancestor_tags if at not in direct_tags}, - ] + tags.sort(key=lambda t: sort_key(t[1])) + # Use order from Tag.name or TagAlias.name depending on which comes first for each tag. + # Value=0 to avoid unnecessary copying of tag names. + tag_ids = list(dict((id, 0) for id, _ in tags).keys()) logger.info( "searching tags", search=name, limit=limit, statement=str(query), - results=len(res), + results=len(tag_ids), ) + tag_ids = tag_ids[:limit] - session.expunge_all() + all_ids = set(tag_ids) + for tag_id in tag_ids: + if len(all_ids) >= limit: + break + for id in session.scalars(TAG_CHILDREN_QUERY, {"tag_id": tag_id}): + all_ids.add(id) + if len(all_ids) >= limit: + break - return res + hierarchy = self.get_tag_hierarchy(all_ids) + + direct_tags = [hierarchy.pop(id) for id in tag_ids] + + all_ids.difference_update(tag_ids) + descendant_tags = [hierarchy.pop(id) for id in all_ids] + descendant_tags.sort(key=lambda t: sort_key(t.name)) + + return direct_tags, descendant_tags def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: """Set the path field of an entry. Returns True if the action succeeded and False if the path already exists. """ - if self.has_path_entry(path): + if self.has_entry_with_path(path): return False if isinstance(entry_id, Entry): entry_id = entry_id.id @@ -1170,195 +1337,119 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: session.commit() return True - def remove_tag(self, tag_id: int): + def remove_tag(self, tag_id: int) -> bool: with Session(self.engine, expire_on_commit=False) as session: try: - aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag_id)) - for alias in aliases: - session.delete(alias) - session.flush() - - tag_parents = session.scalars( - select(TagParent).where(TagParent.parent_id == tag_id) - ).all() - for tag_parent in tag_parents: - session.delete(tag_parent) - session.flush() - - disam_stmt = ( + session.execute(delete(TagAlias).where(TagAlias.tag_id == tag_id)) + session.execute(delete(TagEntry).where(TagEntry.tag_id == tag_id)) + session.execute( + delete(TagParent).where( + or_(TagParent.child_id == tag_id, TagParent.parent_id == tag_id) + ) + ) + session.execute( update(Tag) .where(Tag.disambiguation_id == tag_id) .values(disambiguation_id=None) ) - session.execute(disam_stmt) - session.flush() - - session.query(Tag).filter_by(id=tag_id).delete() + session.execute(delete(Tag).where(Tag.id == tag_id)) session.commit() except IntegrityError as e: logger.error(e) session.rollback() - - def update_field_position( - self, - field_class: type[BaseField], - field_type: str, - entry_ids: list[int] | int, - ): - if isinstance(entry_ids, int): - entry_ids = [entry_ids] - - with Session(self.engine) as session: - for entry_id in entry_ids: - rows = list( - session.scalars( - select(field_class) - .where( - and_( - field_class.entry_id == entry_id, - field_class.type_key == field_type, - ) - ) - .order_by(field_class.id) - ) - ) - - # Reassign `order` starting from 0 - for index, row in enumerate(rows): - row.position = index - session.add(row) - session.flush() - if rows: - session.commit() + return False + return True def remove_entry_field( self, field: BaseField, entry_ids: list[int], ) -> None: - FieldClass = type(field) # noqa: N806 + field_type = type(field) logger.info( "remove_entry_field", field=field, + type=field_type, entry_ids=entry_ids, - field_type=field.type, - cls=FieldClass, - pos=field.position, ) with Session(self.engine) as session: # remove all fields matching entry and field_type - delete_stmt = delete(FieldClass).where( + delete_stmt = delete(field_type).where( and_( - FieldClass.position == field.position, - FieldClass.type_key == field.type_key, - FieldClass.entry_id.in_(entry_ids), + field_type.id == field.id, ) ) session.execute(delete_stmt) - session.commit() - # recalculate the remaining positions - # self.update_field_position(type(field), field.type, entry_ids) + def update_text_field( + self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool + ): + """Update a TextField field on one or more Entries.""" + if isinstance(entry_ids, int): + entry_ids = [entry_ids] + + field_type = type(field) - def update_entry_field( + with Session(self.engine) as session: + update_stmt = ( + update(field_type) + .where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids))) + .values(value=value, is_multiline=is_multiline) + ) + + session.execute(update_stmt) + session.commit() + + def update_datetime_field( self, entry_ids: list[int] | int, - field: BaseField, - content: str | datetime, + field: DatetimeField, + value: datetime, ): + """Update a DatetimeField field on one or more Entries.""" if isinstance(entry_ids, int): entry_ids = [entry_ids] - FieldClass = type(field) # noqa: N806 + field_type = type(field) with Session(self.engine) as session: update_stmt = ( - update(FieldClass) - .where( - and_( - FieldClass.position == field.position, - FieldClass.type == field.type, - FieldClass.entry_id.in_(entry_ids), - ) - ) - .values(value=content) + update(field_type) + .where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids))) + .values(value=value) ) session.execute(update_stmt) session.commit() - @property - def field_types(self) -> dict[str, ValueType]: - with Session(self.engine) as session: - return {x.key: x for x in session.scalars(select(ValueType)).all()} - - def get_value_type(self, field_key: str) -> ValueType: - with Session(self.engine) as session: - field = unwrap(session.scalar(select(ValueType).where(ValueType.key == field_key))) - session.expunge(field) - return field + def add_field_to_entries(self, entry_ids: list[int] | int, field: BaseField) -> bool: + """Add a field object to an Entry.""" + if isinstance(entry_ids, int): + entry_ids = [entry_ids] - def add_field_to_entry( - self, - entry_id: int, - *, - field: ValueType | None = None, - field_id: FieldID | str | None = None, - value: str | datetime | None = None, - ) -> bool: logger.info( - "[Library][add_field_to_entry]", - entry_id=entry_id, - field_type=field, - field_id=field_id, - value=value, + "[Library] Adding field to entry", + type=field.class_name, + entry_ids=entry_ids, + name=field.name, + value=field.value, ) - # supply only instance or ID, not both - assert bool(field) != (field_id is not None) - - if not field: - if isinstance(field_id, FieldID): - 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}") with Session(self.engine) as session: - try: - field_model.entry_id = entry_id - session.add(field_model) - session.flush() - session.commit() - except IntegrityError as e: - logger.error(e) - session.rollback() - return False - # TODO - trigger error signal + for entry_id in entry_ids: + try: + session.add(field.clone_with_entry_id(entry_id)) + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + return False - # recalculate the positions of fields - self.update_field_position( - field_class=type(field_model), - field_type=field.key, - entry_ids=entry_id, - ) return True def tag_from_strings(self, strings: list[str] | str) -> list[int]: @@ -1376,7 +1467,7 @@ def tag_from_strings(self, strings: list[str] | str) -> list[int]: if tag: tags.append(tag.id) else: - new = session.add(Tag(name=string)) # type: ignore + new = session.add(Tag(name=string)) if new: tags.append(new.id) session.flush() @@ -1484,7 +1575,7 @@ def add_tag( return None def add_tags_to_entries( - self, entry_ids: int | list[int] | set[int], tag_ids: int | list[int] | set[int] + self, entry_ids: int | Iterable[int], tag_ids: int | Iterable[int] ) -> int: """Add one or more tags to one or more entries. @@ -1500,45 +1591,57 @@ def add_tags_to_entries( entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids + values: list[tuple[int, int]] = [] + for tag_id in tag_ids_: + values.extend((tag_id, entry_id) for entry_id in entry_ids_) + with Session(self.engine, expire_on_commit=False) as session: - for tag_id in tag_ids_: - for entry_id in entry_ids_: - try: - session.add(TagEntry(tag_id=tag_id, entry_id=entry_id)) - total_added += 1 - session.commit() - except IntegrityError: - session.rollback() + for sub_list in [ + values[i : i + MAX_SQL_VARIABLES // 2] + for i in range(0, len(values), MAX_SQL_VARIABLES // 2) + ]: + stmt = ( + sqlite.insert(TagEntry) + .values(sub_list) + .on_conflict_do_nothing() + .returning(TagEntry) + ) + added = session.scalars(stmt).all() + total_added += len(added) + session.commit() return total_added def remove_tags_from_entries( - self, entry_ids: int | list[int] | set[int], tag_ids: int | list[int] | set[int] - ) -> bool: + self, entry_ids: int | Iterable[int], tag_ids: int | Iterable[int] + ): """Remove one or more tags from one or more entries.""" - entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids - tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids + logger.info( + "[Library][remove_tags_from_entries]", + entry_ids=entry_ids, + tag_ids=tag_ids, + ) + + entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else list(entry_ids) + tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else list(tag_ids) + with Session(self.engine, expire_on_commit=False) as session: - try: - for tag_id in tag_ids_: - for entry_id in entry_ids_: - tag_entry = session.scalars( - select(TagEntry).where( - and_( - TagEntry.tag_id == tag_id, - TagEntry.entry_id == entry_id, - ) - ) - ).first() - if tag_entry: - session.delete(tag_entry) - session.flush() - session.commit() - return True - except IntegrityError as e: - logger.error(e) - session.rollback() - return False + for tags_sub_list in [ + tag_ids_[i : i + MAX_SQL_VARIABLES // 2] + for i in range(0, len(tag_ids_), MAX_SQL_VARIABLES // 2) + ]: + for entries_sub_list in [ + entry_ids_[i : i + MAX_SQL_VARIABLES // 2] + for i in range(0, len(entry_ids_), MAX_SQL_VARIABLES // 2) + ]: + stmt = delete(TagEntry).where( + and_( + TagEntry.tag_id.in_(tags_sub_list), + TagEntry.entry_id.in_(entries_sub_list), + ) + ) + session.execute(stmt) + session.commit() def add_color(self, color_group: TagColorGroup) -> TagColorGroup | None: with Session(self.engine, expire_on_commit=False) as session: @@ -1851,19 +1954,20 @@ def get_version(self, key: str) -> int: engine = sqlalchemy.inspect(self.engine) try: # "Version" table added in DB_VERSION 101 - if engine and engine.has_table("Version"): + if engine and engine.has_table("versions"): version = session.scalar(select(Version).where(Version.key == key)) assert version return version.value # NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4 # and is set to be removed in a future release. else: - pref_version = session.scalar( - select(Preferences).where(Preferences.key == DB_VERSION_LEGACY_KEY) + return int( + unwrap( + session.scalar( + text("SELECT value FROM preferences WHERE key == 'DB_VERSION'") + ) + ) ) - assert pref_version - assert isinstance(pref_version.value, int) - return pref_version.value except Exception: return 0 @@ -1881,93 +1985,46 @@ def set_version(self, key: str, value: int) -> None: version.value = value session.add(version) session.commit() - - # If a depreciated "Preferences" table is found, update the version value to be read - # by older TagStudio versions. - engine = sqlalchemy.inspect(self.engine) - if engine and engine.has_table("Preferences"): - pref = unwrap( - session.scalar( - select(Preferences).where(Preferences.key == DB_VERSION_LEGACY_KEY) - ) - ) - pref.value = value # pyright: ignore - session.add(pref) - session.commit() except (IntegrityError, AssertionError) as e: logger.error("[Library][ERROR] Couldn't add default tag color namespaces", error=e) session.rollback() - # TODO: Remove this once the 'preferences' table is removed. - @deprecated("Use `get_version() for version and `ts_ignore` system for extension exclusion.") - def prefs(self, key: str | LibraryPrefs): # pyright: ignore[reportUnknownParameterType] - # load given item from Preferences table - with Session(self.engine) as session: - if isinstance(key, LibraryPrefs): - return unwrap( - session.scalar(select(Preferences).where(Preferences.key == key.name)) - ).value # pyright: ignore[reportUnknownVariableType] - else: - return unwrap( - session.scalar(select(Preferences).where(Preferences.key == key)) - ).value # pyright: ignore[reportUnknownVariableType] - - # TODO: Remove this once the 'preferences' table is removed. - @deprecated("Use `get_version() for version and `ts_ignore` system for extension exclusion.") - def set_prefs(self, key: str | LibraryPrefs, value: Any) -> None: # pyright: ignore[reportExplicitAny] - # set given item in Preferences table - with Session(self.engine) as session: - # load existing preference and update value - stuff = session.scalars(select(Preferences)) - logger.info([x.key for x in list(stuff)]) - - pref: Preferences = unwrap( - session.scalar( - select(Preferences).where( - Preferences.key == (key.name if isinstance(key, LibraryPrefs) else key) - ) - ) - ) - - logger.info("loading pref", pref=pref, key=key, value=value) - pref.value = value - session.add(pref) - session.commit() - # TODO - try/except - - def mirror_entry_fields(self, *entries: Entry) -> None: + def mirror_entry_fields(self, entries: list[Entry]) -> None: """Mirror fields among multiple Entry items.""" - fields = {} - # load all fields - existing_fields = {field.type_key for field in entries[0].fields} + all_fields: set[BaseField] = set() + logger.info("[Library][mirror_fields]", all_fields=all_fields) + + # Track all fields across all entries for entry in entries: - for entry_field in entry.fields: - fields[entry_field.type_key] = entry_field + for field in entry.fields: + all_fields.add(field) + logger.info( + "[Library][mirror_fields]", entry_id=entry.id, field_count_before=len(entry.fields) + ) - # assign the field to all entries + # Apply all (remaining) fields to all entries, avoiding duplicates for entry in entries: - for field_key, field in fields.items(): # pyright: ignore[reportUnknownVariableType] - if field_key not in existing_fields: - self.add_field_to_entry( - entry_id=entry.id, - field_id=field.type_key, - value=field.value, - ) + for field in all_fields: + if field not in entry.fields: + self.add_field_to_entries(entry_ids=entry.id, field=field) def merge_entries(self, from_entry: Entry, into_entry: Entry) -> bool: """Add fields and tags from the first entry to the second, and then delete the first.""" - success = True - for field in from_entry.fields: - result = self.add_field_to_entry( - entry_id=into_entry.id, - field_id=field.type_key, - value=field.value, + success = False + + try: + self.mirror_entry_fields([from_entry, into_entry]) + tag_ids = [tag.id for tag in from_entry.tags] + self.add_tags_to_entries(into_entry.id, tag_ids) + self.remove_entries([from_entry.id]) + success = True + except Exception as e: + logger.error( + "[Library][merge_entries] Could not merge entires", + error=e, + from_entry_id=from_entry.id, + into_entry_id=into_entry.id, ) - if not result: - success = False - tag_ids = [tag.id for tag in from_entry.tags] - self.add_tags_to_entries(into_entry.id, tag_ids) - self.remove_entries([from_entry.id]) return success diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index f5c315310..c2b81f662 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -1,21 +1,18 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from datetime import datetime as dt from pathlib import Path from typing import override -from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship -from typing_extensions import deprecated from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE from tagstudio.core.library.alchemy.db import Base, PathType -from tagstudio.core.library.alchemy.enums import FieldTypeEnum from tagstudio.core.library.alchemy.fields import ( BaseField, - BooleanField, DatetimeField, TextField, ) @@ -224,7 +221,6 @@ def fields(self) -> list[BaseField]: fields: list[BaseField] = [] fields.extend(self.text_fields) fields.extend(self.datetime_fields) - fields = sorted(fields, key=lambda field: field.type.position) return fields @property @@ -276,67 +272,6 @@ def remove_tag(self, tag: Tag) -> None: self.tags.remove(tag) -class ValueType(Base): - """Define Field Types in the Library. - - Example: - key: content_tags (this field is slugified `name`) - name: Content Tags (this field is human readable name) - kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox) - is_default: Should the field be present in new Entry? - order: position of the field widget in the Entry form - - """ - - __tablename__ = "value_type" - - key: Mapped[str] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(nullable=False) - type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE) - is_default: Mapped[bool] # pyright: ignore[reportUninitializedInstanceVariable] - position: Mapped[int] # pyright: ignore[reportUninitializedInstanceVariable] - - # add relations to other tables - text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type") - datetime_fields: Mapped[list[DatetimeField]] = relationship( - "DatetimeField", back_populates="type" - ) - boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type") - - @property - def as_field(self) -> BaseField: - FieldClass = { # noqa: N806 - FieldTypeEnum.TEXT_LINE: TextField, - FieldTypeEnum.TEXT_BOX: TextField, - FieldTypeEnum.DATETIME: DatetimeField, - FieldTypeEnum.BOOLEAN: BooleanField, - } - - return FieldClass[self.type]( - type_key=self.key, - position=self.position, - ) - - -@event.listens_for(ValueType, "before_insert") -def slugify_field_key(mapper, connection, target): # pyright: ignore - """Slugify the field key before inserting into the database.""" - if not target.key: - from tagstudio.core.library.alchemy.library import slugify - - target.key = slugify(target.tag) - - -# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4 -# and is set to be removed in a future release. -@deprecated("Use `Version` for storing version, and `ts_ignore` system for file exclusion.") -class Preferences(Base): - __tablename__ = "preferences" - - key: Mapped[str] = mapped_column(primary_key=True) - value: Mapped[dict] = mapped_column(JSON, nullable=False) - - class Version(Base): __tablename__ = "versions" diff --git a/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py b/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py index d99a4f498..011479203 100644 --- a/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py +++ b/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py @@ -1,12 +1,16 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + import xml.etree.ElementTree as ET from dataclasses import dataclass, field from pathlib import Path import structlog -from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.utils.types import unwrap logger = structlog.get_logger() @@ -28,7 +32,7 @@ def refresh_dupe_files(self, results_filepath: str | Path): A duplicate file is defined as an identical or near-identical file as determined by a DupeGuru results file. """ - library_dir = self.library.library_dir + library_dir = unwrap(self.library.library_dir) if not isinstance(results_filepath, Path): results_filepath = Path(results_filepath) @@ -43,7 +47,7 @@ def refresh_dupe_files(self, results_filepath: str | Path): files: list[Entry] = [] for element in group: if element.tag == "file": - file_path = Path(element.attrib.get("path")) + file_path = Path(unwrap(element.attrib.get("path"))) try: path_relative = file_path.relative_to(library_dir) @@ -51,16 +55,12 @@ def refresh_dupe_files(self, results_filepath: str | Path): # The file is not in the library directory continue - results = self.library.search_library( - BrowsingState.from_path(path_relative), 500 - ) - entries = self.library.get_entries(results.ids) - - if not results: + entry = self.library.get_entry_full_by_path(path_relative) + if entry is None: # file not in library continue - files.append(entries[0]) + files.append(entry) if not len(files) > 1: # only one file in the group, nothing to do @@ -82,5 +82,5 @@ def merge_dupe_entries(self): for i, entries in enumerate(self.groups): remove_ids = entries[1:] logger.info("Removing entries group", ids=remove_ids) - self.library.remove_entries(remove_ids) + self.library.remove_entries([e.id for e in remove_ids]) yield i - 1 # The -1 waits for the next step to finish diff --git a/src/tagstudio/core/library/alchemy/registries/ignored_registry.py b/src/tagstudio/core/library/alchemy/registries/ignored_registry.py index a864e248e..5401c61bb 100644 --- a/src/tagstudio/core/library/alchemy/registries/ignored_registry.py +++ b/src/tagstudio/core/library/alchemy/registries/ignored_registry.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Iterator diff --git a/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py b/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py index 8058df85f..3f2e85394 100644 --- a/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py +++ b/src/tagstudio/core/library/alchemy/registries/unlinked_registry.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path diff --git a/src/tagstudio/core/library/alchemy/visitors.py b/src/tagstudio/core/library/alchemy/visitors.py index 7f9fc7993..16f5102e4 100644 --- a/src/tagstudio/core/library/alchemy/visitors.py +++ b/src/tagstudio/core/library/alchemy/visitors.py @@ -1,12 +1,12 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import re from typing import TYPE_CHECKING, override import structlog -from sqlalchemy import ColumnElement, and_, distinct, func, or_, select +from sqlalchemy import ColumnElement, and_, distinct, false, func, or_, select from sqlalchemy.orm import Session from sqlalchemy.sql.operators import ilike_op @@ -47,21 +47,21 @@ def __init__(self, lib: Library) -> None: self.lib = lib @override - def visit_or_list(self, node: ORList) -> ColumnElement[bool]: # type: ignore + def visit_or_list(self, node: ORList) -> ColumnElement[bool]: tag_ids, bool_expressions = self.__separate_tags(node.elements, only_single=False) if len(tag_ids) > 0: bool_expressions.append(self.__entry_has_any_tags(tag_ids)) return or_(*bool_expressions) @override - def visit_and_list(self, node: ANDList) -> ColumnElement[bool]: # type: ignore + def visit_and_list(self, node: ANDList) -> ColumnElement[bool]: tag_ids, bool_expressions = self.__separate_tags(node.terms, only_single=True) if len(tag_ids) > 0: bool_expressions.append(self.__entry_has_all_tags(tag_ids)) return and_(*bool_expressions) @override - def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: # type: ignore + def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: """Returns a Boolean Expression that is true, if the Entry satisfies the constraint.""" if len(node.properties) != 0: raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG @@ -113,11 +113,11 @@ def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: # type: ig raise NotImplementedError("This type of constraint is not implemented yet") @override - def visit_property(self, node: Property) -> ColumnElement[bool]: # type: ignore + def visit_property(self, node: Property) -> ColumnElement[bool]: raise NotImplementedError("This should never be reached!") @override - def visit_not(self, node: Not) -> ColumnElement[bool]: # type: ignore + def visit_not(self, node: Not) -> ColumnElement[bool]: return ~self.visit(node.child) def __get_tag_ids(self, tag_name: str, include_children: bool = True) -> list[int]: @@ -163,6 +163,9 @@ def __separate_tags( continue case ConstraintType.Tag: ids = self.__get_tag_ids(term.value) + if len(ids) == 0: + bool_expressions.append(false()) + continue if not only_single: tag_ids.update(ids) continue diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index e3eda7ed1..63b7ab18e 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from copy import deepcopy from pathlib import Path @@ -91,6 +91,23 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]: return glob_patterns +def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str: + # read template + ts_ignore_template = ( + Path(__file__).parents[2] / "resources/templates/ts_ignore_template_blank.txt" + ) + with open(ts_ignore_template) as f: + out = f.read() + + # actual conversion + prefix = "" + if not is_exclude_list: + prefix = "!" + out += "*\n" + out += "\n".join([f"{prefix}*.{x.lstrip('.')}\n" for x in exts]) + return out + + class Ignore(metaclass=Singleton): """Class for processing and managing glob-like file ignore file patterns.""" diff --git a/src/tagstudio/core/library/json/fields.py b/src/tagstudio/core/library/json/fields.py index 5e3509e40..7797096a1 100644 --- a/src/tagstudio/core/library/json/fields.py +++ b/src/tagstudio/core/library/json/fields.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + BOX_FIELDS = ["tag_box", "text_box"] TEXT_FIELDS = ["text_line", "text_box"] DATE_FIELDS = ["datetime"] diff --git a/src/tagstudio/core/library/json/library.py b/src/tagstudio/core/library/json/library.py index 813672012..e3370e2a2 100644 --- a/src/tagstudio/core/library/json/library.py +++ b/src/tagstudio/core/library/json/library.py @@ -1,8 +1,8 @@ # type: ignore # ruff: noqa -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + """The Library object and related methods for TagStudio.""" diff --git a/src/tagstudio/core/library/refresh.py b/src/tagstudio/core/library/refresh.py index 1b2115cdd..824cae527 100644 --- a/src/tagstudio/core/library/refresh.py +++ b/src/tagstudio/core/library/refresh.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import shutil @@ -105,8 +104,8 @@ def __get_dir_list(self, library_dir: Path, ignore_patterns: list[str]) -> list[ ), cwd=library_dir, capture_output=True, - text=True, shell=True, + encoding="UTF-8", ) compiled_ignore_path.unlink() @@ -145,7 +144,7 @@ def __rg_add(self, library_dir: Path, dir_list: list[str]) -> Iterator[int]: dir_file_count += 1 self.library.included_files.add(f) - if not self.library.has_path_entry(f): + if not self.library.has_entry_with_path(f): self.files_not_in_library.append(f) end_time_total = time() @@ -190,7 +189,7 @@ def __wc_add(self, library_dir: Path, ignore_patterns: list[str]) -> Iterator[in relative_path = f.relative_to(library_dir) - if not self.library.has_path_entry(relative_path): + if not self.library.has_entry_with_path(relative_path): self.files_not_in_library.append(relative_path) except ValueError: logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!") diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 8659c389d..b18290d13 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import mimetypes @@ -33,6 +32,7 @@ class MediaType(str, Enum): AUDIO_MIDI = "audio_midi" AUDIO = "audio" BLENDER = "blender" + CLIP_STUDIO_PAINT = "clip_studio_paint" CODE = "code" DATABASE = "database" DISK_IMAGE = "disk_image" @@ -46,9 +46,11 @@ class MediaType(str, Enum): INSTALLER = "installer" IWORK = "iwork" MATERIAL = "material" + MDIPACK = "mdipack" MODEL = "model" OPEN_DOCUMENT = "open_document" PACKAGE = "package" + PAINT_DOT_NET = "paint_dot_net" PDF = "pdf" PLAINTEXT = "plaintext" PRESENTATION = "presentation" @@ -175,6 +177,7 @@ class MediaCategories: ".blend31", ".blend32", } + _CLIP_STUDIO_PAINT_SET: set[str] = {".clip"} _CODE_SET: set[str] = { ".bat", ".cfg", @@ -335,6 +338,7 @@ class MediaCategories: _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} _IWORK_SET: set[str] = {".key", ".pages", ".numbers"} _MATERIAL_SET: set[str] = {".mtl"} + _MDIPACK_SET: set[str] = {".mdp"} _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} _OPEN_DOCUMENT_SET: set[str] = { ".fodg", @@ -358,6 +362,7 @@ class MediaCategories: ".pkg", ".xapk", } + _PAINT_DOT_NET_SET: set[str] = {".pdn"} _PDF_SET: set[str] = {".pdf"} _PLAINTEXT_SET: set[str] = { ".csv", @@ -452,6 +457,12 @@ class MediaCategories: is_iana=False, name="blender", ) + CLIP_STUDIO_PAINT_TYPES = MediaCategory( + media_type=MediaType.CLIP_STUDIO_PAINT, + extensions=_CLIP_STUDIO_PAINT_SET, + is_iana=False, + name="clip studio paint", + ) CODE_TYPES = MediaCategory( media_type=MediaType.CODE, extensions=_CODE_SET, @@ -536,6 +547,12 @@ class MediaCategories: is_iana=False, name="material", ) + MDIPACK_TYPES = MediaCategory( + media_type=MediaType.MDIPACK, + extensions=_MDIPACK_SET, + is_iana=False, + name="mdipack", + ) MODEL_TYPES = MediaCategory( media_type=MediaType.MODEL, extensions=_MODEL_SET, @@ -554,6 +571,12 @@ class MediaCategories: is_iana=False, name="package", ) + PAINT_DOT_NET_TYPES = MediaCategory( + media_type=MediaType.PAINT_DOT_NET, + extensions=_PAINT_DOT_NET_SET, + is_iana=False, + name="paint.net", + ) PDF_TYPES = MediaCategory( media_type=MediaType.PDF, extensions=_PDF_SET, @@ -628,6 +651,7 @@ class MediaCategories: AUDIO_MIDI_TYPES, AUDIO_TYPES, BLENDER_TYPES, + CLIP_STUDIO_PAINT_TYPES, DATABASE_TYPES, DISK_IMAGE_TYPES, DOCUMENT_TYPES, @@ -640,9 +664,11 @@ class MediaCategories: INSTALLER_TYPES, IWORK_TYPES, MATERIAL_TYPES, + MDIPACK_TYPES, MODEL_TYPES, OPEN_DOCUMENT_TYPES, PACKAGE_TYPES, + PAINT_DOT_NET_TYPES, PDF_TYPES, PLAINTEXT_TYPES, PRESENTATION_TYPES, @@ -679,7 +705,7 @@ def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = Args: ext (str): File extension with a leading "." and in all lowercase. - media_cat (MediaCategory): The MediaCategory to to check for extension membership. + media_cat (MediaCategory): The MediaCategory to check for extension membership. mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ return media_cat.contains(ext, mime_fallback) diff --git a/src/tagstudio/core/query_lang/ast.py b/src/tagstudio/core/query_lang/ast.py index 0323bf26d..166c9dd70 100644 --- a/src/tagstudio/core/query_lang/ast.py +++ b/src/tagstudio/core/query_lang/ast.py @@ -1,11 +1,10 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT from abc import ABC, abstractmethod from enum import Enum -from typing import Generic, TypeVar, override +from typing import TypeVar, override class ConstraintType(Enum): @@ -98,7 +97,7 @@ def __init__(self, child: AST) -> None: T = TypeVar("T") -class BaseVisitor(ABC, Generic[T]): +class BaseVisitor[T](ABC): def visit(self, node: AST) -> T: if isinstance(node, ANDList): return self.visit_and_list(node) diff --git a/src/tagstudio/core/query_lang/parser.py b/src/tagstudio/core/query_lang/parser.py index ff17465d7..1dc336c5c 100644 --- a/src/tagstudio/core/query_lang/parser.py +++ b/src/tagstudio/core/query_lang/parser.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from tagstudio.core.query_lang.ast import ( diff --git a/src/tagstudio/core/query_lang/tokenizer.py b/src/tagstudio/core/query_lang/tokenizer.py index a279fbf69..fc6e66851 100644 --- a/src/tagstudio/core/query_lang/tokenizer.py +++ b/src/tagstudio/core/query_lang/tokenizer.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from enum import Enum @@ -61,7 +60,7 @@ class Tokenizer: pos: int current_char: str | None - ESCAPABLE_CHARS = ["\\", '"', '"'] + ESCAPABLE_CHARS = ["\\", '"', "'"] NOT_IN_ULITERAL = [":", " ", "[", "]", "(", ")", "=", ","] def __init__(self, text: str) -> None: diff --git a/src/tagstudio/core/query_lang/util.py b/src/tagstudio/core/query_lang/util.py index 95e53dbea..e0c2b7199 100644 --- a/src/tagstudio/core/query_lang/util.py +++ b/src/tagstudio/core/query_lang/util.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT from typing import override diff --git a/src/tagstudio/core/ts_core.py b/src/tagstudio/core/ts_core.py index 19aebae88..752e1d4a8 100644 --- a/src/tagstudio/core/ts_core.py +++ b/src/tagstudio/core/ts_core.py @@ -1,183 +1,53 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + """The core classes and methods of TagStudio.""" -import json -from pathlib import Path +import re +from functools import lru_cache +import requests import structlog -from tagstudio.core.constants import TS_FOLDER_NAME -from tagstudio.core.library.alchemy.fields import FieldID from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry logger = structlog.get_logger(__name__) +MOST_RECENT_RELEASE_VERSION: str | None = None + class TagStudioCore: def __init__(self): self.lib: Library = Library() - @classmethod - def get_gdl_sidecar(cls, filepath: Path, source: str = "") -> dict: - """Attempt to open and dump a Gallery-DL Sidecar file for the filepath. - - Return a formatted object with notable values or an empty object if none is found. - """ - info = {} - _filepath = filepath.parent / (filepath.name + ".json") - - # NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar - # files may be downloaded with indices starting at 1 rather than 0, unlike the posts. - # This may only occur with sidecar files that are downloaded separate from posts. - if source == "instagram" and not _filepath.is_file(): - newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:] - _filepath = _filepath.parent / (newstem + ".json") - - logger.info("get_gdl_sidecar", filepath=filepath, source=source, sidecar=_filepath) - - try: - with open(_filepath, encoding="utf8") as f: - json_dump = json.load(f) - if not json_dump: - return {} - - if source == "twitter": - info[FieldID.DESCRIPTION] = json_dump["content"].strip() - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - elif source == "instagram": - info[FieldID.DESCRIPTION] = json_dump["description"].strip() - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - elif source == "artstation": - info[FieldID.TITLE] = json_dump["title"].strip() - info[FieldID.ARTIST] = json_dump["user"]["full_name"].strip() - info[FieldID.DESCRIPTION] = json_dump["description"].strip() - info[FieldID.TAGS] = json_dump["tags"] - # info["tags"] = [x for x in json_dump["mediums"]["name"]] - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - elif source == "newgrounds": - # info["title"] = json_dump["title"] - # info["artist"] = json_dump["artist"] - # info["description"] = json_dump["description"] - info[FieldID.TAGS] = json_dump["tags"] - info[FieldID.DATE_PUBLISHED] = json_dump["date"] - info[FieldID.ARTIST] = json_dump["user"].strip() - info[FieldID.DESCRIPTION] = json_dump["description"].strip() - info[FieldID.SOURCE] = json_dump["post_url"].strip() - - except Exception: - logger.exception("Error handling sidecar file.", path=_filepath) - - return info - - # def scrape(self, entry_id): - # entry = self.lib.get_entry(entry_id) - # if entry.fields: - # urls: list[str] = [] - # if self.lib.get_field_index_in_entry(entry, 21): - # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') - # for x in self.lib.get_field_index_in_entry(entry, 21)]) - # if self.lib.get_field_index_in_entry(entry, 3): - # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') - # for x in self.lib.get_field_index_in_entry(entry, 3)]) - # # try: - # if urls: - # for url in urls: - # url = "https://" + url if 'https://' not in url else url - # html_doc = requests.get(url).text - # soup = bs(html_doc, "html.parser") - # print(soup) - # input() - - # # except: - # # # print("Could not resolve URL.") - # # pass - - @classmethod - def match_conditions(cls, lib: Library, entry_id: int) -> bool: - """Match defined conditions against a file to add Entry data.""" - # TODO - what even is this file format? - # TODO: Make this stored somewhere better instead of temporarily in this JSON file. - cond_file = lib.library_dir / TS_FOLDER_NAME / "conditions.json" - if not cond_file.is_file(): - return False - - entry: Entry = lib.get_entry(entry_id) - - try: - with open(cond_file, encoding="utf8") as f: - json_dump = json.load(f) - for c in json_dump["conditions"]: - match: bool = False - for path_c in c["path_conditions"]: - if Path(path_c).is_relative_to(entry.path): - match = True - break - - if not match: - return False - - if not c.get("fields"): - return False - - fields = c["fields"] - entry_field_types = {field.type_key: field for field in entry.fields} - - for field in fields: - is_new = field["id"] not in entry_field_types - field_key = field["id"] - if is_new: - lib.add_field_to_entry(entry.id, field_key, field["value"]) - else: - lib.update_entry_field(entry.id, field_key, field["value"]) - - except Exception: - logger.exception("Error matching conditions.", entry=entry) - - return False - - @classmethod - def build_url(cls, entry: Entry, source: str): - """Try to rebuild a source URL given a specific filename structure.""" - source = source.lower().replace("-", " ").replace("_", " ") - if "twitter" in source: - return cls._build_twitter_url(entry) - elif "instagram" in source: - return cls._build_instagram_url(entry) - - @classmethod - def _build_twitter_url(cls, entry: Entry): - """Build a Twitter URL given a specific filename structure. - - Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD' - """ - try: - stubs = str(entry.path.name).rsplit("_", 3) - url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}" - return url - except Exception: - logger.exception("Error building Twitter URL.", entry=entry) - return "" - - @classmethod - def _build_instagram_url(cls, entry: Entry): - """Build an Instagram URL given a specific filename structure. - - Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD' - """ + @staticmethod + @lru_cache(maxsize=1) + def get_most_recent_release_version() -> str | None: + """Get the version of the most recent GitHub release.""" try: - stubs = str(entry.path.name).rsplit("_", 2) - # stubs[0] = stubs[0].replace(f"{author}_", '', 1) - # print(stubs) - # NOTE: Both Instagram usernames AND their ID can have underscores in them, - # so unless you have the exact username (which can change) on hand to remove, - # your other best bet is to hope that the ID is only 11 characters long, which - # seems to more or less be the case... for now... - url = f"www.instagram.com/p/{stubs[-3][-11:]}" - return url - except Exception: - logger.exception("Error building Instagram URL.", entry=entry) - return "" + resp = requests.get( + "https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest" + ) + except Exception as e: + logger.error("Error getting most recent GitHub release.", error=e) + return None + + if resp.status_code != 200: + logger.error("Error getting most recent GitHub release.", status_code=resp.status_code) + return None + + data = resp.json() + tag: str = data["tag_name"] + if not tag.startswith("v"): + logger.error("Unexpected tag format.", tag=tag) + return None + + version = tag[1:] + # the assertion does not allow for prerelease/build, + # because the latest release should never have them + if re.match(r"^\d+\.\d+\.\d+$", version) is None: + logger.error("Invalid version format.", version=version) + return None + + return version diff --git a/src/tagstudio/core/utils/encoding.py b/src/tagstudio/core/utils/encoding.py index f5a4a0d90..73265f011 100644 --- a/src/tagstudio/core/utils/encoding.py +++ b/src/tagstudio/core/utils/encoding.py @@ -1,6 +1,6 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from pathlib import Path diff --git a/src/tagstudio/core/utils/silent_subprocess.py b/src/tagstudio/core/utils/silent_subprocess.py index 680e947f1..d4d818a4c 100644 --- a/src/tagstudio/core/utils/silent_subprocess.py +++ b/src/tagstudio/core/utils/silent_subprocess.py @@ -1,8 +1,6 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only -# pyright: reportExplicitAny=false import os import subprocess diff --git a/src/tagstudio/core/utils/singleton.py b/src/tagstudio/core/utils/singleton.py index 76b6bf081..82b4518da 100644 --- a/src/tagstudio/core/utils/singleton.py +++ b/src/tagstudio/core/utils/singleton.py @@ -1,10 +1,11 @@ -# Based off example from Refactoring Guru: -# https://refactoring.guru/design-patterns/singleton/python/example#example-1 -# Adapted for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from threading import Lock +# See also: https://refactoring.guru/design-patterns/singleton/python/example#example-1 class Singleton(type): """A thread-safe implementation of a Singleton.""" diff --git a/src/tagstudio/core/utils/str_formatting.py b/src/tagstudio/core/utils/str_formatting.py index e4a870cc6..9fa41ebf7 100644 --- a/src/tagstudio/core/utils/str_formatting.py +++ b/src/tagstudio/core/utils/str_formatting.py @@ -1,6 +1,8 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import semver def strip_punctuation(string: str) -> str: @@ -32,3 +34,18 @@ def strip_web_protocol(string: str) -> str: for prefix in prefixes: string = string.removeprefix(prefix) return string + + +def is_version_outdated(current: str, latest: str) -> bool: + vcur = semver.Version.parse(current) + vlat = semver.Version.parse(latest) + assert vlat.prerelease is None and vlat.build is None + + if vcur.major != vlat.major: + return vcur.major < vlat.major + elif vcur.minor != vlat.minor: + return vcur.minor < vlat.minor + elif vcur.patch != vlat.patch: + return vcur.patch < vlat.patch + else: + return vcur.prerelease is not None or vcur.build is not None diff --git a/src/tagstudio/core/utils/types.py b/src/tagstudio/core/utils/types.py index d21feb95c..57970334e 100644 --- a/src/tagstudio/core/utils/types.py +++ b/src/tagstudio/core/utils/types.py @@ -1,5 +1,6 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from typing import TypeVar diff --git a/src/tagstudio/main.py b/src/tagstudio/main.py index 70411a27c..7997884d4 100755 --- a/src/tagstudio/main.py +++ b/src/tagstudio/main.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only """TagStudio launcher.""" diff --git a/src/tagstudio/qt/cache_manager.py b/src/tagstudio/qt/cache_manager.py index e15ead686..00d812ff3 100644 --- a/src/tagstudio/qt/cache_manager.py +++ b/src/tagstudio/qt/cache_manager.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import math from collections.abc import Iterable diff --git a/src/tagstudio/qt/controllers/ffmpeg_missing_message_box.py b/src/tagstudio/qt/controllers/ffmpeg_missing_message_box.py index 3de27f888..a3c711760 100644 --- a/src/tagstudio/qt/controllers/ffmpeg_missing_message_box.py +++ b/src/tagstudio/qt/controllers/ffmpeg_missing_message_box.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + from shutil import which import structlog diff --git a/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py b/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py index 6afc25f8a..e841f61d5 100644 --- a/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py +++ b/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override @@ -32,7 +31,7 @@ def __init__(self, library: "Library", driver: "QtDriver"): lambda: ( self.update_ignored_count(), self.driver.update_browsing_state(), - self.driver.library_info_window.update_cleanup(), + self.update_driver_widgets(), self.refresh_ignored(), ) ) @@ -52,20 +51,13 @@ def refresh_ignored(self): pw.setWindowTitle(Translations["library.scan_library.title"]) pw.update_label(Translations["entries.ignored.scanning"]) - def update_driver_widgets(): - if ( - hasattr(self.driver, "library_info_window") - and self.driver.library_info_window.isVisible() - ): - self.driver.library_info_window.update_cleanup() - pw.from_iterable_function( self.tracker.refresh_ignored_entries, None, self.set_ignored_count, self.update_ignored_count, self.remove_modal.refresh_list, - update_driver_widgets, + self.update_driver_widgets, ) def set_ignored_count(self): @@ -88,7 +80,14 @@ def update_ignored_count(self): ) self.ignored_count_label.setText(f"

    {count_text}

    ") + def update_driver_widgets(self): + if ( + hasattr(self.driver, "library_info_window") + and self.driver.library_info_window.isVisible() + ): + self.driver.library_info_window.update_cleanup() + @override - def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore + def showEvent(self, event: QtGui.QShowEvent) -> None: self.update_ignored_count() return super().showEvent(event) diff --git a/src/tagstudio/qt/controllers/ignore_modal_controller.py b/src/tagstudio/qt/controllers/ignore_modal_controller.py index 66937d5b4..cbefd0ae3 100644 --- a/src/tagstudio/qt/controllers/ignore_modal_controller.py +++ b/src/tagstudio/qt/controllers/ignore_modal_controller.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path @@ -47,6 +46,6 @@ def save(self): Ignore.write_ignore_file(self.lib.library_dir, lines) @override - def showEvent(self, event: QShowEvent) -> None: # type: ignore + def showEvent(self, event: QShowEvent) -> None: self.__load_file() return super().showEvent(event) diff --git a/src/tagstudio/qt/controllers/library_info_window_controller.py b/src/tagstudio/qt/controllers/library_info_window_controller.py index e435a7988..426e1edd4 100644 --- a/src/tagstudio/qt/controllers/library_info_window_controller.py +++ b/src/tagstudio/qt/controllers/library_info_window_controller.py @@ -1,6 +1,7 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + import os from pathlib import Path from typing import TYPE_CHECKING, override @@ -65,7 +66,7 @@ def update_title(self): def update_stats(self): self.entry_count_label.setText(f"{self.lib.entries_count}") self.tag_count_label.setText(f"{len(self.lib.tags)}") - self.field_count_label.setText(f"{len(self.lib.field_types)}") + self.field_count_label.setText(f"{len(self.lib.field_templates)}") self.namespaces_count_label.setText(f"{len(self.lib.namespaces)}") colors_total = 0 for c in self.lib.tag_color_groups.values(): @@ -161,6 +162,6 @@ def __backups_size(self): return size @override - def showEvent(self, event: QtGui.QShowEvent): # type: ignore + def showEvent(self, event: QtGui.QShowEvent): self.refresh() return super().showEvent(event) diff --git a/src/tagstudio/qt/controllers/out_of_date_message_box.py b/src/tagstudio/qt/controllers/out_of_date_message_box.py new file mode 100644 index 000000000..d22c148ce --- /dev/null +++ b/src/tagstudio/qt/controllers/out_of_date_message_box.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox + +from tagstudio.core.constants import GITHUB_RELEASE_URL, VERSION +from tagstudio.core.ts_core import TagStudioCore +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.translations import Translations + +logger = structlog.get_logger(__name__) + + +class OutOfDateMessageBox(QMessageBox): + """A warning dialog for if the TagStudio is not running under the latest release version.""" + + def __init__(self): + super().__init__() + + title = Translations.format("version_modal.title") + self.setWindowTitle(title) + self.setIcon(QMessageBox.Icon.Warning) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.setStandardButtons( + QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel + ) + self.setDefaultButton(QMessageBox.StandardButton.Ignore) + # Enables the cancel button but hides it to allow for click X to close dialog + self.button(QMessageBox.StandardButton.Cancel).hide() + + red = get_ui_color(ColorType.PRIMARY, UiColor.RED) + green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN) + latest_release_version = unwrap(TagStudioCore.get_most_recent_release_version()) + status = Translations.format( + "version_modal.status", + installed_version=f"{VERSION}", + latest_release_version=f"{latest_release_version}", + ) + description = Translations.format( + "version_modal.description", github_url=GITHUB_RELEASE_URL + ) + self.setText(f"{description}

    {status}") diff --git a/src/tagstudio/qt/controllers/paged_panel_controller.py b/src/tagstudio/qt/controllers/paged_panel_controller.py index ddffe554f..f4a7e8e8b 100644 --- a/src/tagstudio/qt/controllers/paged_panel_controller.py +++ b/src/tagstudio/qt/controllers/paged_panel_controller.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import override diff --git a/src/tagstudio/qt/controllers/paged_panel_state.py b/src/tagstudio/qt/controllers/paged_panel_state.py index 368cc4d40..a239ca76a 100644 --- a/src/tagstudio/qt/controllers/paged_panel_state.py +++ b/src/tagstudio/qt/controllers/paged_panel_state.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtWidgets import QPushButton diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index ba62c1fad..0f4af3007 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -1,5 +1,6 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import typing from warnings import catch_warnings @@ -22,12 +23,15 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__add_field_modal = AddFieldModal(self.lib) self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + @typing.override def _add_field_button_callback(self): self.__add_field_modal.show() + @typing.override def _add_tag_button_callback(self): self.__add_tag_modal.show() + @typing.override def _set_selection_callback(self): with catch_warnings(record=True): self.__add_field_modal.done.disconnect() diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index ecc5d96a2..db9c2f330 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -1,9 +1,10 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import io from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import cv2 import rawpy @@ -11,6 +12,10 @@ from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import QSize +from rawpy import ( + LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] + LibRawIOError, # pyright: ignore[reportPrivateImportUsage] +) from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -49,8 +54,8 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: stats.width = image.width stats.height = image.height except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] + LibRawIOError, + LibRawFileUnsupportedError, FileNotFoundError, ): pass @@ -143,18 +148,22 @@ def display_file(self, filepath: Path) -> FileAttributeData: self._display_image(filepath) return self.__get_image_stats(filepath) + @override def _open_file_action_callback(self): open_file( self.__current_file, windows_start_command=self.__driver.settings.windows_start_command ) + @override def _open_explorer_action_callback(self): open_file(self.__current_file, file_manager=True) + @override def _delete_action_callback(self): if bool(self.__current_file): self.__driver.delete_files_callback(self.__current_file) + @override def _button_wrapper_callback(self): open_file( self.__current_file, windows_start_command=self.__driver.settings.windows_start_command diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 70bfc6162..a0306be5a 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -1,5 +1,5 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override @@ -57,7 +57,7 @@ def set_tags(self, tags): # type: ignore[override] super().set_tags(tags_, partial_tag_ids=partial_tag_ids) @override - def _on_click(self, tag: Tag) -> None: # type: ignore[misc] + def _on_click(self, tag: Tag) -> None: match self.__driver.settings.tag_click_action: case TagClickActionOption.OPEN_EDIT: self._on_edit(tag) @@ -81,7 +81,7 @@ def _on_click(self, tag: Tag) -> None: # type: ignore[misc] ) @override - def _on_remove(self, tag: Tag) -> None: # type: ignore[misc] + def _on_remove(self, tag: Tag) -> None: logger.info( "[TagBoxWidget] remove_tag", selected=self.__entries, @@ -93,7 +93,7 @@ def _on_remove(self, tag: Tag) -> None: # type: ignore[misc] self.on_update.emit() @override - def _on_edit(self, tag: Tag) -> None: # type: ignore[misc] + def _on_edit(self, tag: Tag) -> None: build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag) edit_modal = PanelModal( @@ -115,7 +115,7 @@ def _on_edit(self, tag: Tag) -> None: # type: ignore[misc] edit_modal.show() @override - def _on_search(self, tag: Tag) -> None: # type: ignore[misc] + def _on_search(self, tag: Tag) -> None: self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}") self.__driver.update_browsing_state( BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current) diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 052d815e7..94be1f076 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -1,5 +1,6 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import platform from datetime import datetime @@ -63,6 +64,7 @@ class GlobalSettings(BaseModel): cached_thumb_quality: int = Field(default=DEFAULT_CACHED_IMAGE_QUALITY) cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_IMAGE_RES) autoplay: bool = Field(default=True) + scan_files_on_open: bool = Field(default=True) loop: bool = Field(default=True) show_filenames_in_grid: bool = Field(default=True) page_size: int = Field(default=100) diff --git a/src/tagstudio/qt/helpers/color_overlay.py b/src/tagstudio/qt/helpers/color_overlay.py index 8fd432631..0d276210d 100644 --- a/src/tagstudio/qt/helpers/color_overlay.py +++ b/src/tagstudio/qt/helpers/color_overlay.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PIL import Image diff --git a/src/tagstudio/qt/helpers/escape_text.py b/src/tagstudio/qt/helpers/escape_text.py index 989ef1141..0885d173c 100644 --- a/src/tagstudio/qt/helpers/escape_text.py +++ b/src/tagstudio/qt/helpers/escape_text.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only def escape_text(text: str): diff --git a/src/tagstudio/qt/helpers/file_tester.py b/src/tagstudio/qt/helpers/file_tester.py index d899f7e65..89e202959 100644 --- a/src/tagstudio/qt/helpers/file_tester.py +++ b/src/tagstudio/qt/helpers/file_tester.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path diff --git a/src/tagstudio/qt/helpers/gradients.py b/src/tagstudio/qt/helpers/gradients.py index 1e672868b..004a38529 100644 --- a/src/tagstudio/qt/helpers/gradients.py +++ b/src/tagstudio/qt/helpers/gradients.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PIL import Image diff --git a/src/tagstudio/qt/helpers/image_effects.py b/src/tagstudio/qt/helpers/image_effects.py index ca764744d..3af6cf3cf 100644 --- a/src/tagstudio/qt/helpers/image_effects.py +++ b/src/tagstudio/qt/helpers/image_effects.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import numpy as np diff --git a/src/tagstudio/qt/helpers/text_wrapper.py b/src/tagstudio/qt/helpers/text_wrapper.py index 71d6b5be4..5ac29e637 100644 --- a/src/tagstudio/qt/helpers/text_wrapper.py +++ b/src/tagstudio/qt/helpers/text_wrapper.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PIL import Image, ImageDraw, ImageFont @@ -8,7 +7,7 @@ def wrap_line( text: str, - font: ImageFont.ImageFont, + font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, width: int = 256, draw: ImageDraw.ImageDraw | None = None, ) -> int: @@ -32,7 +31,7 @@ def wrap_line( def wrap_full_text( text: str, - font: ImageFont.ImageFont, + font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, width: int = 256, draw: ImageDraw.ImageDraw | None = None, ) -> str: diff --git a/src/tagstudio/qt/mixed/about_modal.py b/src/tagstudio/qt/mixed/about_modal.py index e7d4a2ecb..3d1092f1d 100644 --- a/src/tagstudio/qt/mixed/about_modal.py +++ b/src/tagstudio/qt/mixed/about_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import math @@ -20,6 +19,8 @@ from tagstudio.core.constants import VERSION, VERSION_BRANCH from tagstudio.core.enums import Theme +from tagstudio.core.ts_core import TagStudioCore +from tagstudio.core.utils.types import unwrap from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.previews.vendored import ffmpeg from tagstudio.qt.resource_manager import ResourceManager @@ -103,6 +104,19 @@ def __init__(self, config_path): self.system_info_layout = QFormLayout(self.system_info_widget) self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + # Version + version_title = QLabel("Version") + most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN") + version_content_style = self.form_content_style + if most_recent_release == VERSION: + version_content = QLabel(f"{VERSION}") + else: + version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})") + version_content_style += "color: #d9534f;" + version_content.setStyleSheet(version_content_style) + version_content.setMaximumWidth(version_content.sizeHint().width()) + self.system_info_layout.addRow(version_title, version_content) + # License license_title = QLabel(f"{Translations['about.license']}") license_content = QLabel("GPLv3") diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py index 917dab50d..52b64e5ea 100644 --- a/src/tagstudio/qt/mixed/add_field.py +++ b/src/tagstudio/qt/mixed/add_field.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import override @@ -19,7 +18,7 @@ ) from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.translations import Translations +from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations logger = structlog.get_logger(__name__) @@ -73,13 +72,18 @@ def __init__(self, library: Library): self.root_layout.addStretch(1) self.root_layout.addWidget(self.button_container) + @override def show(self): self.list_widget.clear() - for df in self.lib.field_types.values(): - item = QListWidgetItem(f"{df.name} ({df.type.value})") - item.setData(Qt.ItemDataRole.UserRole, df.key) + for field_template in self.lib.field_templates: + field_name_key: str = FIELD_TYPE_KEYS.get( + field_template.class_name, "field_type.unknown" + ) + item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})") + item.setData(Qt.ItemDataRole.UserRole, field_template) self.list_widget.addItem(item) self.list_widget.setFocus() + self.list_widget.setCurrentRow(0) super().show() diff --git a/src/tagstudio/qt/mixed/build_color.py b/src/tagstudio/qt/mixed/build_color.py index a33ef435d..07b43d88e 100644 --- a/src/tagstudio/qt/mixed/build_color.py +++ b/src/tagstudio/qt/mixed/build_color.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import contextlib diff --git a/src/tagstudio/qt/mixed/build_namespace.py b/src/tagstudio/qt/mixed/build_namespace.py index c7b4ed238..5b91a7001 100644 --- a/src/tagstudio/qt/mixed/build_namespace.py +++ b/src/tagstudio/qt/mixed/build_namespace.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import contextlib diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index cfffb6595..a6aef61ff 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import sys @@ -522,7 +521,7 @@ def _set_aliases(self): self.alias_names.clear() - last: QWidget = self.panel_save_button + last: QWidget | None = self.panel_save_button for alias_id in self.alias_ids: alias = self.lib.get_alias(self.tag.id, alias_id) @@ -549,7 +548,8 @@ def _set_aliases(self): self.aliases_table.setCellWidget(row, 1, new_item) self.aliases_table.setCellWidget(row, 0, remove_btn) - self.setTabOrder(last, self.aliases_table.cellWidget(row, 1)) + if last is not None: + self.setTabOrder(last, self.aliases_table.cellWidget(row, 1)) self.setTabOrder( self.aliases_table.cellWidget(row, 1), self.aliases_table.cellWidget(row, 0) ) @@ -624,3 +624,4 @@ def parent_post_init(self): self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1)) self.name_field.selectAll() self.name_field.setFocus() + self._set_aliases() diff --git a/src/tagstudio/qt/mixed/collage_icon.py b/src/tagstudio/qt/mixed/collage_icon.py index 13930085b..105aff345 100644 --- a/src/tagstudio/qt/mixed/collage_icon.py +++ b/src/tagstudio/qt/mixed/collage_icon.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import math diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py index 20866c438..3a031f2ac 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import typing @@ -139,7 +138,7 @@ def edit_color(self, color_group: TagColorGroup): ) self.edit_modal.saved.connect( - lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) # type: ignore + lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) ) self.edit_modal.show() diff --git a/src/tagstudio/qt/mixed/datetime_picker.py b/src/tagstudio/qt/mixed/datetime_picker.py index b059dd96b..318c4768a 100644 --- a/src/tagstudio/qt/mixed/datetime_picker.py +++ b/src/tagstudio/qt/mixed/datetime_picker.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + import typing from collections.abc import Callable from datetime import datetime as dt diff --git a/src/tagstudio/qt/mixed/drop_import_modal.py b/src/tagstudio/qt/mixed/drop_import_modal.py index 766ce6083..570448b54 100644 --- a/src/tagstudio/qt/mixed/drop_import_modal.py +++ b/src/tagstudio/qt/mixed/drop_import_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import enum diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index febc3c8e2..ac549e2c7 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import sys @@ -16,6 +15,7 @@ QFrame, QGraphicsOpacityEffect, QHBoxLayout, + QListWidgetItem, QMessageBox, QScrollArea, QSizePolicy, @@ -24,9 +24,9 @@ ) from tagstudio.core.enums import Theme -from tagstudio.core.library.alchemy.enums import FieldTypeEnum from tagstudio.core.library.alchemy.fields import ( BaseField, + BaseFieldTemplate, DatetimeField, TextField, ) @@ -37,7 +37,7 @@ 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.translations import Translations +from tagstudio.qt.translations import FIELD_TYPE_KEYS, 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.panel_modal import PanelModal @@ -185,7 +185,12 @@ def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: for field in first_entry_fields: if all( - any(f.type_key == field.type_key and f.value == field.value for f in entry.fields) + any( + f.name == field.name + and type(f) is type(field) + and f.value == field.value + for f in entry.fields + ) for entry in entries[1:] ): shared_fields.append(field) @@ -194,10 +199,10 @@ def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: 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[str, list[BaseField]] = {} + 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.type_key, []).append(field) + all_fields_by_type.setdefault((field.name, field.class_name), []).append(field) shared_fields: list[BaseField] = [] mixed_fields: list[BaseField] = [] @@ -314,7 +319,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: def remove_field_prompt(self, name: str) -> str: return Translations.format("library.field.confirm_remove", name=name) - def add_field_to_selected(self, field_list: list): + def add_field_to_selected(self, field_list: list[QListWidgetItem]): """Add list of entry fields to one or more selected items. Uses the current driver selection, NOT the field containers cache. @@ -325,11 +330,14 @@ def add_field_to_selected(self, field_list: list): fields=field_list, ) for entry_id in self.driver.selected: - for field_item in field_list: - self.lib.add_field_to_entry( - entry_id, - field_id=field_item.data(Qt.ItemDataRole.UserRole), + for field in field_list: + template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole) + logger.info( + "[FieldContainers][add_field_to_selected] Adding field", + name=template.name, + type=template.class_name, ) + self.lib.add_field_to_entries(entry_id, template.to_field()) def add_tags_to_selected(self, tags: int | list[int]): """Add list of tags to one or more selected items. @@ -343,11 +351,7 @@ def add_tags_to_selected(self, tags: int | list[int]): selected=self.driver.selected, tags=tags, ) - self.lib.add_tags_to_entries( - self.driver.selected, - tag_ids=tags, - ) - self.driver.emit_badge_signals(tags, emit_on_absent=False) + self.driver.add_tags_to_selected_callback(tags) def set_container_partial(self, container: FieldContainer, partial: bool) -> None: """Apply a visual partial-selection treatment to a container.""" @@ -368,7 +372,12 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): If True, field is not present in all selected items. """ - logger.info("[FieldContainers][write_field_container]", index=index) + logger.info( + "[FieldContainers][write_container]", + index=index, + name=field.name, + type=field.class_name, + ) if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -381,8 +390,13 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback() container.set_remove_callback() - if field.type.type == FieldTypeEnum.TEXT_LINE: - container.set_title(field.type.name) + # 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]})" + + # Single-line Text + if type(field) is TextField and not field.is_multiline: + container.set_title(field.name) container.set_inline(False) # Normalize line endings in any text content. @@ -390,19 +404,18 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): assert isinstance(field.value, str | type(None)) text = field.value or "" else: - text = "Mixed Data" + text = "Mixed Data" # TODO: Localize this - title = f"{field.type.name} ({field.type.type.value})" inner_widget = TextWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextLine(field.value), title=title, - window_title=f"Edit {field.type.type.value}", - save_callback=( + window_title=f"Edit {field.name}", # TODO: Localize this + save_callback=( # pyright: ignore[reportArgumentType] lambda content: ( - self.update_field(field, content), # type: ignore + self.update_text_field(field, content, is_multiline=False), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -414,7 +427,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.type.value), + prompt=self.remove_field_prompt(title), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -422,26 +435,26 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) - elif field.type.type == FieldTypeEnum.TEXT_BOX: - container.set_title(field.type.name) + # Multiline Text + elif type(field) is TextField and field.is_multiline: + container.set_title(field.name) container.set_inline(False) # Normalize line endings in any text content. if not is_mixed: assert isinstance(field.value, str | type(None)) text = (field.value or "").replace("\r", "\n") else: - text = "Mixed Data" - title = f"{field.type.name} (Text Box)" + text = "Mixed Data" # TODO: Localize this inner_widget = TextWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextBox(field.value), title=title, - window_title=f"Edit {field.type.name}", - save_callback=( + window_title=f"Edit {field.name}", # TODO: Localize this + save_callback=( # pyright: ignore[reportArgumentType] lambda content: ( - self.update_field(field, content), # type: ignore + self.update_text_field(field, content, is_multiline=True), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -449,7 +462,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=self.remove_field_prompt(field.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -457,20 +470,18 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) - elif field.type.type == FieldTypeEnum.DATETIME: + elif type(field) is DatetimeField: logger.info("[FieldContainers][write_container] Datetime Field", field=field) if not is_mixed: - container.set_title(field.type.name) + container.set_title(field.name) container.set_inline(False) - title = f"{field.type.name} (Date)" try: assert field.value is not None text = self.driver.settings.format_datetime( DatetimePicker.string2dt(field.value) ) except (ValueError, AssertionError): - title += " (Unknown Format)" text = str(field.value) inner_widget = TextWidget(title, text) @@ -478,10 +489,10 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): modal = PanelModal( DatetimePicker(self.driver, field.value or dt.now()), - title=f"Edit {field.type.name}", - save_callback=( + title=f"Edit {field.name}", + save_callback=( # pyright: ignore[reportArgumentType] lambda content: ( - self.update_field(field, content), # type: ignore + self.update_datetime_field(field, content), self.update_from_entry(self.cached_entries[0].id), ) ), @@ -490,7 +501,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=self.remove_field_prompt(field.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -498,20 +509,20 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) else: - text = "Mixed Data" - title = f"{field.type.name} (Wacky Date)" + text = "Mixed Data" # TODO: Localize this inner_widget = TextWidget(title, text) container.set_inner_widget(inner_widget) else: - logger.warning("[FieldContainers][write_container] Unknown Field", field=field) - container.set_title(field.type.name) + logger.warning( + "[FieldContainers][write_container] Unknown Field", field=field + ) # TODO: Localize this + container.set_title(field.name) container.set_inline(False) - title = f"{field.type.name} (Unknown Field Type)" - inner_widget = TextWidget(title, field.type.name) + inner_widget = TextWidget(title, field.name) container.set_inner_widget(inner_widget) container.set_remove_callback( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=self.remove_field_prompt(field.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.cached_entries[0].id), @@ -563,7 +574,9 @@ def write_tag_container( container = self.containers[index] self.set_container_partial(container, is_mixed) - container.set_title("Tags" if not category_tag else category_tag.name) + container.set_title( + "Tags" if not category_tag else category_tag.name + ) # TODO: Localize this container.set_inline(False) inner_widget = container.get_inner_widget() @@ -573,7 +586,7 @@ def write_tag_container( inner_widget.on_update.disconnect() else: inner_widget = TagBoxWidget( - "Tags", + "Tags", # TODO: Localize this self.driver, ) container.set_inner_widget(inner_widget) @@ -610,26 +623,24 @@ 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: - """Update a field in all selected Entries, given a field object.""" - assert isinstance( - field, - TextField | DatetimeField, - ), f"instance: {type(field)}" - + def update_text_field(self, field: TextField, value: str, is_multiline: bool): + """Update a text field across selected entries.""" entry_ids = [e.id for e in self.cached_entries] + assert entry_ids, "No entries selected" + + self.lib.update_text_field(entry_ids, field, value, is_multiline) + def update_datetime_field(self, field: DatetimeField, value: str): + """Update a datetime field across selected entries.""" + entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" - self.lib.update_entry_field( - entry_ids, - field, - content, - ) + + self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value)) def remove_message_box(self, prompt: str, callback: Callable) -> None: remove_mb = QMessageBox() remove_mb.setText(prompt) - remove_mb.setWindowTitle("Remove Field") + remove_mb.setWindowTitle("Remove Field") # TODO: Localize remove_mb.setIcon(QMessageBox.Icon.Warning) cancel_button = remove_mb.addButton( Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/mixed/field_widget.py index d2678b556..04ee3c37f 100644 --- a/src/tagstudio/qt/mixed/field_widget.py +++ b/src/tagstudio/qt/mixed/field_widget.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import math diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index 5704e6e9e..38dfd3f09 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import os @@ -115,6 +114,7 @@ def update_date_label(self, filepath: Path | None = None) -> None: if filepath and filepath.is_file(): created: dt if platform.system() == "Windows" or platform.system() == "Darwin": + # NOTE: Accessing stat().st_birthtime causes linter checks to fail on some systems. created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] else: created = dt.fromtimestamp(filepath.stat().st_ctime) diff --git a/src/tagstudio/qt/mixed/fix_dupe_files.py b/src/tagstudio/qt/mixed/fix_dupe_files.py index c215e862b..c7c3165bb 100644 --- a/src/tagstudio/qt/mixed/fix_dupe_files.py +++ b/src/tagstudio/qt/mixed/fix_dupe_files.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override diff --git a/src/tagstudio/qt/mixed/fix_unlinked.py b/src/tagstudio/qt/mixed/fix_unlinked.py index 63a1affc6..59474c1f9 100644 --- a/src/tagstudio/qt/mixed/fix_unlinked.py +++ b/src/tagstudio/qt/mixed/fix_unlinked.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override diff --git a/src/tagstudio/qt/mixed/folders_to_tags.py b/src/tagstudio/qt/mixed/folders_to_tags.py index f7051f775..45cdbc0a3 100644 --- a/src/tagstudio/qt/mixed/folders_to_tags.py +++ b/src/tagstudio/qt/mixed/folders_to_tags.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import math diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py index d2f1f9856..81223316d 100644 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ b/src/tagstudio/qt/mixed/item_thumb.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from enum import Enum @@ -468,12 +467,12 @@ def show_check_badges(self, show: bool): badge.setHidden(is_hidden) @override - def enterEvent(self, event: QEnterEvent) -> None: # type: ignore[misc] + def enterEvent(self, event: QEnterEvent) -> None: self.show_check_badges(show=True) return super().enterEvent(event) @override - def leaveEvent(self, event: QEvent) -> None: # type: ignore[misc] + def leaveEvent(self, event: QEvent) -> None: self.show_check_badges(show=False) return super().leaveEvent(event) @@ -496,16 +495,14 @@ def toggle_item_tag( toggle_value: bool, tag_id: int, ): - if entry_id in self.driver.selected: - if len(self.driver.selected) == 1: - self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag( - tag_id, toggle_value - ) - else: - pass + selected = self.driver._selected + if len(selected) == 1 and entry_id in selected: + self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag( + tag_id, toggle_value + ) @override - def mouseMoveEvent(self, event: QMouseEvent) -> None: # type: ignore[misc] + def mouseMoveEvent(self, event: QMouseEvent) -> None: if event.buttons() is not Qt.MouseButton.LeftButton: return diff --git a/src/tagstudio/qt/mixed/landing.py b/src/tagstudio/qt/mixed/landing.py index 050994acf..7f5f6ba72 100644 --- a/src/tagstudio/qt/mixed/landing.py +++ b/src/tagstudio/qt/mixed/landing.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import sys diff --git a/src/tagstudio/qt/mixed/media_player.py b/src/tagstudio/qt/mixed/media_player.py index 065e73d40..c180964a3 100644 --- a/src/tagstudio/qt/mixed/media_player.py +++ b/src/tagstudio/qt/mixed/media_player.py @@ -1,6 +1,6 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import typing from pathlib import Path @@ -324,7 +324,7 @@ def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool: """Manage events for the media player.""" if ( arg__2.type() == QEvent.Type.MouseButtonPress - and arg__2.button() == Qt.MouseButton.LeftButton # type: ignore + and arg__2.button() == Qt.MouseButton.LeftButton # pyright: ignore[reportAttributeAccessIssue] ): if arg__1 == self.play_pause: self.toggle_play() diff --git a/src/tagstudio/qt/mixed/merge_dupe_entries.py b/src/tagstudio/qt/mixed/merge_dupe_entries.py index af56a0bf9..0091bb3a8 100644 --- a/src/tagstudio/qt/mixed/merge_dupe_entries.py +++ b/src/tagstudio/qt/mixed/merge_dupe_entries.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import typing diff --git a/src/tagstudio/qt/mixed/migration_modal.py b/src/tagstudio/qt/mixed/migration_modal.py index 56fd2bbb3..0859b0ec5 100644 --- a/src/tagstudio/qt/mixed/migration_modal.py +++ b/src/tagstudio/qt/mixed/migration_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import traceback @@ -8,6 +7,7 @@ from typing import cast import structlog +import wcmatch.fnmatch as fnmatch from PySide6.QtCore import QObject, Qt, QThreadPool, Signal from PySide6.QtWidgets import ( QApplication, @@ -24,18 +24,20 @@ from sqlalchemy.orm import Session from tagstudio.core.constants import ( + IGNORE_NAME, LEGACY_TAG_FIELD_IDS, TAG_ARCHIVED, TAG_FAVORITE, TAG_META, TS_FOLDER_NAME, ) -from tagstudio.core.enums import LibraryPrefs from tagstudio.core.library.alchemy import default_color_groups from tagstudio.core.library.alchemy.constants import SQL_FILENAME +from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP from tagstudio.core.library.alchemy.joins import TagParent from tagstudio.core.library.alchemy.library import Library as SqliteLibrary from tagstudio.core.library.alchemy.models import Entry, TagAlias +from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob from tagstudio.core.library.json.library import Library as JsonLibrary from tagstudio.core.library.json.library import Tag as JsonTag from tagstudio.core.utils.types import unwrap @@ -72,8 +74,6 @@ def __init__(self, path: Path): self.old_entry_count: int = 0 self.old_tag_count: int = 0 - self.old_ext_count: int = 0 - self.old_ext_type: bool = None # pyright: ignore[reportAttributeAccessIssue] self.field_parity: bool = False self.path_parity: bool = False @@ -82,6 +82,7 @@ def __init__(self, path: Path): self.subtag_parity: bool = False self.alias_parity: bool = False self.color_parity: bool = False + self.ext_parity: bool = False self.init_page_info() self.init_page_convert() @@ -129,8 +130,7 @@ def init_page_convert(self) -> None: parent_tags_text: str = tab + Translations["json_migration.heading.parent_tags"] aliases_text: str = tab + Translations["json_migration.heading.aliases"] colors_text: str = tab + Translations["json_migration.heading.colors"] - ext_text: str = Translations["json_migration.heading.file_extension_list"] - ext_type_text: str = Translations["json_migration.heading.extension_list_type"] + ext_parity_text: str = Translations["json_migration.heading.extensions"] desc_text: str = Translations["json_migration.description"] path_parity_text: str = tab + Translations["json_migration.heading.paths"] field_parity_text: str = tab + Translations["library_info.stats.fields"] @@ -145,7 +145,6 @@ def init_page_convert(self) -> None: self.aliases_row: int = 7 self.colors_row: int = 8 self.ext_row: int = 9 - self.ext_type_row: int = 10 old_lib_container: QWidget = QWidget() old_lib_layout: QVBoxLayout = QVBoxLayout(old_lib_container) @@ -166,8 +165,7 @@ def init_page_convert(self) -> None: self.old_content_layout.addWidget(QLabel(parent_tags_text), self.parent_tags_row, 0) self.old_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0) self.old_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0) - self.old_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0) - self.old_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0) + self.old_content_layout.addWidget(QLabel(ext_parity_text), self.ext_row, 0) old_entry_count: QLabel = QLabel() old_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -187,10 +185,8 @@ def init_page_convert(self) -> None: old_alias_value.setAlignment(Qt.AlignmentFlag.AlignRight) old_color_value: QLabel = QLabel() old_color_value.setAlignment(Qt.AlignmentFlag.AlignRight) - old_ext_count: QLabel = QLabel() - old_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight) - old_ext_type: QLabel = QLabel() - old_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight) + old_ext_value: QLabel = QLabel() + old_ext_value.setAlignment(Qt.AlignmentFlag.AlignRight) self.old_content_layout.addWidget(old_entry_count, self.entries_row, 1) self.old_content_layout.addWidget(old_path_value, self.path_row, 1) @@ -201,8 +197,7 @@ def init_page_convert(self) -> None: self.old_content_layout.addWidget(old_subtag_value, self.parent_tags_row, 1) self.old_content_layout.addWidget(old_alias_value, self.aliases_row, 1) self.old_content_layout.addWidget(old_color_value, self.colors_row, 1) - self.old_content_layout.addWidget(old_ext_count, self.ext_row, 1) - self.old_content_layout.addWidget(old_ext_type, self.ext_type_row, 1) + self.old_content_layout.addWidget(old_ext_value, self.ext_row, 1) self.old_content_layout.addWidget(QLabel(), self.path_row, 2) self.old_content_layout.addWidget(QLabel(), self.fields_row, 2) @@ -211,6 +206,7 @@ def init_page_convert(self) -> None: self.old_content_layout.addWidget(QLabel(), self.parent_tags_row, 2) self.old_content_layout.addWidget(QLabel(), self.aliases_row, 2) self.old_content_layout.addWidget(QLabel(), self.colors_row, 2) + self.old_content_layout.addWidget(QLabel(), self.ext_row, 2) old_lib_layout.addWidget(old_content_container) @@ -233,8 +229,7 @@ def init_page_convert(self) -> None: self.new_content_layout.addWidget(QLabel(parent_tags_text), self.parent_tags_row, 0) self.new_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0) self.new_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0) - self.new_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0) - self.new_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0) + self.new_content_layout.addWidget(QLabel(ext_parity_text), self.ext_row, 0) new_entry_count: QLabel = QLabel() new_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -254,10 +249,8 @@ def init_page_convert(self) -> None: alias_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) new_color_value: QLabel = QLabel() new_color_value.setAlignment(Qt.AlignmentFlag.AlignRight) - new_ext_count: QLabel = QLabel() - new_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight) - new_ext_type: QLabel = QLabel() - new_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight) + ext_parity_value: QLabel = QLabel() + ext_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) self.new_content_layout.addWidget(new_entry_count, self.entries_row, 1) self.new_content_layout.addWidget(path_parity_value, self.path_row, 1) @@ -268,8 +261,7 @@ def init_page_convert(self) -> None: self.new_content_layout.addWidget(subtag_parity_value, self.parent_tags_row, 1) self.new_content_layout.addWidget(alias_parity_value, self.aliases_row, 1) self.new_content_layout.addWidget(new_color_value, self.colors_row, 1) - self.new_content_layout.addWidget(new_ext_count, self.ext_row, 1) - self.new_content_layout.addWidget(new_ext_type, self.ext_type_row, 1) + self.new_content_layout.addWidget(ext_parity_value, self.ext_row, 1) self.new_content_layout.addWidget(QLabel(), self.entries_row, 2) self.new_content_layout.addWidget(QLabel(), self.path_row, 2) @@ -281,7 +273,6 @@ def init_page_convert(self) -> None: self.new_content_layout.addWidget(QLabel(), self.aliases_row, 2) self.new_content_layout.addWidget(QLabel(), self.colors_row, 2) self.new_content_layout.addWidget(QLabel(), self.ext_row, 2) - self.new_content_layout.addWidget(QLabel(), self.ext_type_row, 2) new_lib_layout.addWidget(new_content_container) @@ -334,8 +325,6 @@ def migrate(self, skip_ui: bool = False): # Update JSON UI self.update_json_entry_count(len(self.json_lib.entries)) self.update_json_tag_count(len(self.json_lib.tags)) - self.update_json_ext_count(len(self.json_lib.ext_list)) - self.update_json_ext_type(self.json_lib.is_exclude_list) self.migration_progress(skip_ui=skip_ui) self.is_migration_initialized = True @@ -375,7 +364,7 @@ def migration_progress(self, skip_ui: bool = False): iterator = FunctionIterator(self.migration_iterator) iterator.value.connect( lambda x: ( - pb.setLabelText(f"

    {x}

    "), # type: ignore + pb.setLabelText(f"

    {x}

    "), self.update_sql_value_ui(show_msg_box=False) if x == Translations["json_migration.checking_for_parity"] else (), @@ -388,8 +377,8 @@ def migration_progress(self, skip_ui: bool = False): r.done.connect( lambda: ( self.update_sql_value_ui(show_msg_box=not skip_ui), - pb.setMinimum(1), # type: ignore - pb.setValue(1), # type: ignore + pb.setMinimum(1), + pb.setValue(1), # Enable the finish button cast(QPushButtonWrapper, self.stack[1].buttons[4]).setDisabled(False), ) @@ -410,11 +399,12 @@ def migration_iterator(self): self.temp_path: Path = ( self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite" ) - self.sql_lib.storage_path = self.temp_path if self.temp_path.exists(): logger.info('Temporary migration file "temp_path" already exists. Removing...') self.temp_path.unlink() - self.sql_lib.open_sqlite_library(self.json_lib.library_dir, is_new=True) + self.sql_lib.open_sqlite_library( + self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path) + ) yield Translations.format( "json_migration.migrating_files_entries", entries=len(self.json_lib.entries) ) @@ -428,6 +418,7 @@ def migration_iterator(self): check_set.add(self.check_subtag_parity()) check_set.add(self.check_alias_parity()) check_set.add(self.check_color_parity()) + check_set.add(self.check_ignore_parity()) if False not in check_set: yield Translations["json_migration.migration_complete"] else: @@ -454,6 +445,7 @@ def update_parity_ui(self): self.update_parity_value(self.parent_tags_row, self.subtag_parity) self.update_parity_value(self.aliases_row, self.alias_parity) self.update_parity_value(self.colors_row, self.color_parity) + self.update_parity_value(self.ext_row, self.ext_parity) self.sql_lib.close() def update_sql_value_ui(self, show_msg_box: bool = True): @@ -468,16 +460,6 @@ def update_sql_value_ui(self, show_msg_box: bool = True): len(self.sql_lib.tags), self.old_tag_count, ) - self.update_sql_value( - self.ext_row, - len(self.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)), - self.old_ext_count, - ) - self.update_sql_value( - self.ext_type_row, - self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST), # pyright: ignore[reportArgumentType] - self.old_ext_type, - ) logger.info("Parity check complete!") if self.discrepancies: logger.warning("Discrepancies found:") @@ -501,36 +483,26 @@ def finish_migration(self): def update_json_entry_count(self, value: int): self.old_entry_count = value - label: QLabel = self.old_content_layout.itemAtPosition(self.entries_row, 1).widget() # type:ignore + label: QLabel = self.old_content_layout.itemAtPosition(self.entries_row, 1).widget() # pyright: ignore[reportAssignmentType] label.setText(self.color_value_default(value)) def update_json_tag_count(self, value: int): self.old_tag_count = value - label: QLabel = self.old_content_layout.itemAtPosition(self.tags_row, 1).widget() # type:ignore - label.setText(self.color_value_default(value)) - - def update_json_ext_count(self, value: int): - self.old_ext_count = value - label: QLabel = self.old_content_layout.itemAtPosition(self.ext_row, 1).widget() # type:ignore - label.setText(self.color_value_default(value)) - - def update_json_ext_type(self, value: bool): - self.old_ext_type = value - label: QLabel = self.old_content_layout.itemAtPosition(self.ext_type_row, 1).widget() # type:ignore + label: QLabel = self.old_content_layout.itemAtPosition(self.tags_row, 1).widget() # pyright: ignore[reportAssignmentType] label.setText(self.color_value_default(value)) def update_sql_value(self, row: int, value: int | bool, old_value: int | bool): - label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore - warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore + label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # pyright: ignore[reportAssignmentType] + warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # pyright: ignore[reportAssignmentType] label.setText(self.color_value_conditional(old_value, value)) warning_icon.setText("" if old_value == value else self.warning) def update_parity_value(self, row: int, value: bool): result: str = self.match_text if value else self.differ_text - old_label: QLabel = self.old_content_layout.itemAtPosition(row, 1).widget() # type:ignore - new_label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore - old_warning_icon: QLabel = self.old_content_layout.itemAtPosition(row, 2).widget() # type:ignore - new_warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore + old_label: QLabel = self.old_content_layout.itemAtPosition(row, 1).widget() # pyright: ignore[reportAssignmentType] + new_label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # pyright: ignore[reportAssignmentType] + old_warning_icon: QLabel = self.old_content_layout.itemAtPosition(row, 2).widget() # pyright: ignore[reportAssignmentType] + new_warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # pyright: ignore[reportAssignmentType] old_label.setText(self.color_value_conditional(self.match_text, result)) new_label.setText(self.color_value_conditional(self.match_text, result)) old_warning_icon.setText("" if value else self.warning) @@ -547,12 +519,31 @@ def color_value_conditional(self, old_value: int | str, new_value: int | str) -> color = green if old_value == new_value else red return str(f"{new_value}") + def assert_ignore_parity(self) -> None: + compiled_pats = fnmatch.compile( + ignore_to_glob( + Ignore._load_ignore_file( + unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME + ) + ), + PATH_GLOB_FLAGS, + ) # copied from Ignore.get_patterns since that method modifies singleton state + path = self.json_lib.library_dir / "filename" + for ext in self.json_lib.ext_list: + assert compiled_pats.match(str(path / ext)) == self.json_lib.is_exclude_list + assert compiled_pats.match(str(path / ".not_a_real_ext")) != self.json_lib.is_exclude_list + + def check_ignore_parity(self) -> bool: + try: + self.assert_ignore_parity() + self.ext_parity = True + except AssertionError: + self.ext_parity = False + return self.ext_parity + def check_field_parity(self) -> bool: """Check if all JSON field and tag data matches the new SQL data.""" - def sanitize_field(entry: Entry, value, type, type_key): - return value if value else None - def sanitize_json_field(value): if isinstance(value, list): return set(value) if value else None @@ -563,7 +554,7 @@ def sanitize_json_field(value): sql_fields: list[tuple] = [] json_fields: list[tuple] = [] - sql_entry: Entry = unwrap(self.sql_lib.get_entry_full(json_entry.id + 1)) + sql_entry: Entry | None = self.sql_lib.get_entry_full(json_entry.id + 1) if not sql_entry: logger.info( "[Field Comparison]", @@ -576,14 +567,13 @@ def sanitize_json_field(value): return self.field_parity for sf in sql_entry.fields: - if sf.type.type.value not in LEGACY_TAG_FIELD_IDS: - sql_fields.append( - ( - sql_entry.id, - sf.type.key, - sanitize_field(sql_entry, sf.value, sf.type.type, sf.type_key), - ) + sql_fields.append( + ( + sql_entry.id, + sf.name.upper().replace(" ", "_"), + sf.value if sf.value else None, ) + ) sql_fields.sort() # NOTE: The JSON database stored tags inside of special "tag field" types which @@ -597,7 +587,7 @@ def sanitize_json_field(value): tags_count += 1 json_tags = json_tags.union(value or []) else: - key: str = unwrap(self.sql_lib.get_field_name_from_id(int_key)).name + key: str = str(LEGACY_FIELD_MAP[int_key]["name"]).upper().replace(" ", "_") json_fields.append((json_entry.id + 1, key, value)) json_fields.sort() @@ -670,9 +660,6 @@ def check_subtag_parity(self) -> bool: self.subtag_parity = True return self.subtag_parity - def check_ext_type(self) -> bool: - return self.json_lib.is_exclude_list == self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST) - def check_alias_parity(self) -> bool: """Check if all JSON aliases match the new SQL aliases.""" with Session(self.sql_lib.engine) as session: diff --git a/src/tagstudio/qt/mixed/mirror_entries_modal.py b/src/tagstudio/qt/mixed/mirror_entries_modal.py index d67cf12ed..d06627a63 100644 --- a/src/tagstudio/qt/mixed/mirror_entries_modal.py +++ b/src/tagstudio/qt/mixed/mirror_entries_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import typing @@ -95,7 +94,7 @@ def mirror_entries_runnable(self): mirrored: list = [] lib = self.driver.lib for i, entries in enumerate(self.tracker.groups): - lib.mirror_entry_fields(*entries) + lib.mirror_entry_fields(entries) sleep(0.005) yield i diff --git a/src/tagstudio/qt/mixed/pagination.py b/src/tagstudio/qt/mixed/pagination.py index e39fc048b..a1e77c1cb 100644 --- a/src/tagstudio/qt/mixed/pagination.py +++ b/src/tagstudio/qt/mixed/pagination.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only """A pagination widget created for TagStudio.""" @@ -219,7 +218,7 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True): if i < index: if (i != 0) and i >= index - 4: self.start_buffer_layout.itemAt(i - start_offset).widget().setHidden(False) - self.start_buffer_layout.itemAt(i - start_offset).widget().setText( # type: ignore + self.start_buffer_layout.itemAt(i - start_offset).widget().setText( # pyright: ignore[reportAttributeAccessIssue] str(i + 1) ) self._assign_click( @@ -238,9 +237,7 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True): elif i > index: if i != page_count - 1 and i <= index + 4: self.end_buffer_layout.itemAt(i - end_offset).widget().setHidden(False) - self.end_buffer_layout.itemAt(i - end_offset).widget().setText( # type: ignore - str(i + 1) - ) + self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue] self._assign_click( cast( QPushButtonWrapper, diff --git a/src/tagstudio/qt/mixed/progress_bar.py b/src/tagstudio/qt/mixed/progress_bar.py index 9dba568a0..ef1bb66a3 100644 --- a/src/tagstudio/qt/mixed/progress_bar.py +++ b/src/tagstudio/qt/mixed/progress_bar.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Callable @@ -63,6 +62,6 @@ def from_iterable_function( r = CustomRunnable(lambda: iterator.run()) r.done.connect( - lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks]) # type: ignore + lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks]) ) QThreadPool.globalInstance().start(r) diff --git a/src/tagstudio/qt/mixed/relink_entries_modal.py b/src/tagstudio/qt/mixed/relink_entries_modal.py index c966f8737..8acd7c1bb 100644 --- a/src/tagstudio/qt/mixed/relink_entries_modal.py +++ b/src/tagstudio/qt/mixed/relink_entries_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtCore import QObject, Signal diff --git a/src/tagstudio/qt/mixed/remove_ignored_modal.py b/src/tagstudio/qt/mixed/remove_ignored_modal.py index 0d45f094b..5977009e9 100644 --- a/src/tagstudio/qt/mixed/remove_ignored_modal.py +++ b/src/tagstudio/qt/mixed/remove_ignored_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override diff --git a/src/tagstudio/qt/mixed/remove_unlinked_modal.py b/src/tagstudio/qt/mixed/remove_unlinked_modal.py index 1b5353cb2..0b4e6b6b0 100644 --- a/src/tagstudio/qt/mixed/remove_unlinked_modal.py +++ b/src/tagstudio/qt/mixed/remove_unlinked_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py index 609ed5b05..5e8c022db 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, Any @@ -176,6 +175,13 @@ def __build_global_settings(self): self.autoplay_checkbox.setChecked(self.driver.settings.autoplay) form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox) + # Scan for new files when a library is opened + self.scan_files_on_open_checkbox = QCheckBox() + self.scan_files_on_open_checkbox.setChecked(self.driver.settings.scan_files_on_open) + form_layout.addRow( + Translations["settings.scan_files_on_open"], self.scan_files_on_open_checkbox + ) + # Show Filenames in Grid self.show_filenames_checkbox = QCheckBox() self.show_filenames_checkbox.setChecked(self.driver.settings.show_filenames_in_grid) @@ -295,6 +301,7 @@ def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] MIN_THUMB_CACHE_SIZE, ), "autoplay": self.autoplay_checkbox.isChecked(), + "scan_files_on_open": self.scan_files_on_open_checkbox.isChecked(), "show_filenames_in_grid": self.show_filenames_checkbox.isChecked(), "page_size": int(self.page_size_line_edit.text()), "infinite_scroll": self.infinite_scroll.isChecked(), @@ -313,6 +320,7 @@ def update_settings(self, driver: "QtDriver"): driver.settings.language = settings["language"] driver.settings.open_last_loaded_on_startup = settings["open_last_loaded_on_startup"] driver.settings.autoplay = settings["autoplay"] + driver.settings.scan_files_on_open = settings["scan_files_on_open"] driver.settings.generate_thumbs = settings["generate_thumbs"] driver.settings.thumb_cache_size = settings["thumb_cache_size"] driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"] diff --git a/src/tagstudio/qt/mixed/tag_color_label.py b/src/tagstudio/qt/mixed/tag_color_label.py index 39d5d96f2..8052f59d8 100644 --- a/src/tagstudio/qt/mixed/tag_color_label.py +++ b/src/tagstudio/qt/mixed/tag_color_label.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import typing diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index d381fcac0..92c3b42b1 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from collections.abc import Callable from typing import TYPE_CHECKING, override diff --git a/src/tagstudio/qt/mixed/tag_color_preview.py b/src/tagstudio/qt/mixed/tag_color_preview.py index 8d8a7e8b0..c73571d4a 100644 --- a/src/tagstudio/qt/mixed/tag_color_preview.py +++ b/src/tagstudio/qt/mixed/tag_color_preview.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import typing diff --git a/src/tagstudio/qt/mixed/tag_color_selection.py b/src/tagstudio/qt/mixed/tag_color_selection.py index e6bacc1ae..a58d3c06f 100644 --- a/src/tagstudio/qt/mixed/tag_color_selection.py +++ b/src/tagstudio/qt/mixed/tag_color_selection.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import structlog diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py index 180cee9c7..81ea08289 100644 --- a/src/tagstudio/qt/mixed/tag_database.py +++ b/src/tagstudio/qt/mixed/tag_database.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import structlog @@ -60,15 +59,15 @@ def delete_tag(self, tag: Tag): return message_box = QMessageBox( - QMessageBox.Question, # type: ignore + QMessageBox.Question, # pyright: ignore[reportAttributeAccessIssue] Translations["tag.remove"], Translations.format("tag.confirm_delete", tag_name=self.lib.tag_display_name(tag)), - QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + QMessageBox.Ok | QMessageBox.Cancel, # pyright: ignore[reportAttributeAccessIssue] ) result = message_box.exec() - if result != QMessageBox.Ok: # type: ignore + if result != QMessageBox.Ok: # pyright: ignore[reportAttributeAccessIssue] return self.lib.remove_tag(tag.id) diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py index 3a3e120b4..e309d5d02 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -1,9 +1,7 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only -import contextlib from typing import TYPE_CHECKING, Union from warnings import catch_warnings @@ -107,9 +105,6 @@ def __init__( self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items]) self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx) self.limit_combobox.currentIndexChanged.connect(self.update_limit) - self.previous_limit: int = ( - TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1 - ) self.limit_layout.addWidget(self.limit_combobox) self.limit_layout.addStretch(1) @@ -218,32 +213,13 @@ def update_tags(self, query: str | None = None): self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget().deleteLater() self.create_button_in_layout = False - # Get results for the search query - query_lower = "" if not query else query.lower() # Only use the tag limit if it's an actual number (aka not "All Tags") tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1 - tag_results: list[set[Tag]] = self.lib.search_tags(name=query, limit=tag_limit) - if self.exclude: - tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude} - tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude} - - # Sort and prioritize the results - results_0 = list(tag_results[0]) - results_0.sort(key=lambda tag: tag.name.lower()) - results_1 = list(tag_results[1]) - results_1.sort(key=lambda tag: tag.name.lower()) - raw_results = list(results_0 + results_1) - priority_results: set[Tag] = set() - all_results: list[Tag] = [] + direct_tags, descendant_tags = self.lib.search_tags(name=query, limit=tag_limit) - if query and query.strip(): - for tag in raw_results: - if tag.name.lower().startswith(query_lower): - priority_results.add(tag) + all_results = [t for t in direct_tags if t.id not in self.exclude] + all_results.extend(t for t in descendant_tags if t.id not in self.exclude) - all_results = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [ - r for r in raw_results if r not in priority_results - ] if tag_limit > 0: all_results = all_results[:tag_limit] @@ -255,15 +231,11 @@ def update_tags(self, query: str | None = None): self.first_tag_id = None # Update every tag widget with the new search result data - norm_previous = self.previous_limit if self.previous_limit > 0 else len(self.lib.tags) - norm_limit = tag_limit if tag_limit > 0 else len(self.lib.tags) - range_limit = max(norm_previous, norm_limit) - for i in range(0, range_limit): - tag = None - with contextlib.suppress(IndexError): - tag = all_results[i] - self.set_tag_widget(tag=tag, index=i) - self.previous_limit = tag_limit + for i in range(0, len(all_results)): + tag = all_results[i] + self.set_tag_widget(tag, i) + for i in range(len(all_results), self.scroll_layout.count()): + self.set_tag_widget(None, i) # Add back the "Create & Add" button if query and query.strip(): @@ -326,6 +298,9 @@ def set_tag_widget(self, tag: Tag | None, index: int): def update_limit(self, index: int): logger.info("[TagSearchPanel] Updating tag limit") + if TagSearchPanel.cur_limit_idx == index: + return + TagSearchPanel.cur_limit_idx = index if index < len(self._limit_items) - 1: @@ -337,9 +312,6 @@ def update_limit(self, index: int): if index != self.limit_combobox.currentIndex(): self.limit_combobox.setCurrentIndex(index) - if self.previous_limit == TagSearchPanel.tag_limit: - return - self.update_tags(self.search_field.text()) def on_return(self, text: str): diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 507ae79c1..52b6e01a1 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Callable diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py index d8052ce96..3d958e3cb 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/mixed/text_field.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import re @@ -34,9 +33,7 @@ def set_text(self, text: str): # Regex from https://stackoverflow.com/a/6041965 def linkify(text: str): - url_pattern = ( - r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-*]*[\w@?^=%&\/~+#-*])" - ) + url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 return re.sub( url_pattern, lambda url: f'{url.group(0)}', diff --git a/src/tagstudio/qt/mnemonics.py b/src/tagstudio/qt/mnemonics.py index d7fd44b68..359e7f448 100644 --- a/src/tagstudio/qt/mnemonics.py +++ b/src/tagstudio/qt/mnemonics.py @@ -1,10 +1,15 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only +import re + +import structlog from PySide6.QtGui import QAction from PySide6.QtWidgets import QMenu +logger = structlog.get_logger(__name__) + def remove_mnemonic_marker(label: str) -> str: """Remove existing accelerator markers (&) from a label.""" @@ -25,6 +30,31 @@ def remove_mnemonic_marker(label: str) -> str: return result +def get_wanted_mnemonics(text: str) -> list[str]: + matches = re.findall("(?:^|[^&])&([^&])", text) + return matches + + +def sanitise_mnemonics(actions: list[QAction]) -> None: + previous = [] + for action in actions: + text = action.text() + m = get_wanted_mnemonics(text) + + if len(m) == 0: + continue + elif len(m) > 1: + logger.warning("Found multiple wanted mnemonics, removing all", text=text) + action.setText(remove_mnemonic_marker(text)) + continue + elif m[0] in previous: + logger.warning("Removing conflicting mnemonic", text=text) + action.setText(remove_mnemonic_marker(text)) + continue + + previous.append(m[0]) + + # Additional weight for first character in string FIRST_CHARACTER_EXTRA_WEIGHT = 50 # Additional weight for the beginning of a word @@ -97,6 +127,9 @@ def assign_mnemonics(menu: QMenu): # Collect actions actions = [a for a in menu.actions() if not a.isSeparator()] + # sanitise mnemonics to prevent deadlocks + sanitise_mnemonics(actions) + # Sequence map: mnemonic key -> QAction sequence_to_action: dict[str, QAction] = {} diff --git a/src/tagstudio/qt/models/palette.py b/src/tagstudio/qt/models/palette.py index 0d658a573..120dab723 100644 --- a/src/tagstudio/qt/models/palette.py +++ b/src/tagstudio/qt/models/palette.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import traceback from enum import IntEnum diff --git a/src/tagstudio/qt/platform_strings.py b/src/tagstudio/qt/platform_strings.py index 6144896f7..91f12a31b 100644 --- a/src/tagstudio/qt/platform_strings.py +++ b/src/tagstudio/qt/platform_strings.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only """A collection of platform-dependant strings.""" diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 566ab7aed..866e3df31 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -1,15 +1,18 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only +import base64 import contextlib import hashlib import math import os +import sqlite3 +import struct import tarfile import xml.etree.ElementTree as ET import zipfile +import zlib from copy import deepcopy from io import BytesIO from pathlib import Path @@ -40,7 +43,7 @@ UnidentifiedImageError, ) from PIL.Image import DecompressionBombError -from pillow_heif import register_heif_opener +from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType] from PySide6.QtCore import ( QBuffer, QFile, @@ -55,6 +58,10 @@ from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from PySide6.QtSvg import QSvgRenderer +from rawpy import ( + LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] + LibRawIOError, # pyright: ignore[reportPrivateImportUsage] +) from tagstudio.core.constants import ( FONT_SAMPLE_SIZES, @@ -72,9 +79,11 @@ from tagstudio.qt.helpers.image_effects import replace_transparent_pixels from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color -from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb +from tagstudio.qt.previews.vendored.blender_renderer import ( + blend_thumb, # pyright: ignore[reportUnknownVariableType] +) from tagstudio.qt.previews.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, + _AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage] ) from tagstudio.qt.resource_manager import ResourceManager @@ -109,22 +118,28 @@ def read(self, name: str) -> bytes: return factory.get(name).read() -class _TarFile(tarfile.TarFile): +class _TarFile: """Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API.""" def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) + self.tar: tarfile.TarFile + self.filepath = filepath + self.mode: Literal["r"] = mode def namelist(self) -> list[str]: - return self.getnames() + return self.tar.getnames() def read(self, name: str) -> bytes: - return unwrap(self.extractfile(name)).read() + return unwrap(self.tar.extractfile(name)).read() + + def __enter__(self) -> "_TarFile": + self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__() + return self + + def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] + self.tar.__exit__(*args) -type _Archive_T = ( - type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_SevenZipFile] | type[_TarFile] -) type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile @@ -284,7 +299,7 @@ def _render_mask( im: Image.Image = Image.new( mode="L", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="black", ) draw = ImageDraw.Draw(im) @@ -315,7 +330,7 @@ def _render_edge( # Highlight im_hl: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="#00000000", ) draw = ImageDraw.Draw(im_hl) @@ -334,7 +349,7 @@ def _render_edge( # Shadow im_sh: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="#00000000", ) draw = ImageDraw.Draw(im_sh) @@ -379,7 +394,7 @@ def _render_center_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="#FF000000", ) @@ -387,13 +402,13 @@ def _render_center_icon( bg: Image.Image bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="#000000FF", ) # Use a background image if provided if bg_image: - bg_im = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg_im = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # pyright: ignore[reportArgumentType] bg_im = ImageEnhance.Brightness(bg_im).enhance(0.3) # Reduce the brightness bg.paste(bg_im) @@ -402,7 +417,7 @@ def _render_center_icon( bg, (0, 0), mask=self._get_mask( - tuple([d * smooth_factor for d in size]), # type: ignore + tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] (pixel_ratio * smooth_factor), ), ) @@ -486,19 +501,19 @@ def _render_corner_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="#00000000", ) bg: Image.Image # Use a background image if provided if bg_image: - bg = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # pyright: ignore[reportArgumentType] # Create solid background color else: bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] color="#000000", ) # Apply color overlay @@ -512,7 +527,7 @@ def _render_corner_icon( bg, (0, 0), mask=self._get_mask( - tuple([d * smooth_factor for d in size]), # type: ignore + tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType] (pixel_ratio * smooth_factor), ), ) @@ -653,17 +668,17 @@ def _audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: artwork = None if ext in [".mp3"]: id3_tags: id3.ID3 = id3.ID3(filepath) - id3_covers: list = id3_tags.getall("APIC") + id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType] if id3_covers: artwork = Image.open(BytesIO(id3_covers[0].data)) elif ext in [".flac"]: flac_tags: flac.FLAC = flac.FLAC(filepath) - flac_covers: list = flac_tags.pictures + flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType] if flac_covers: artwork = Image.open(BytesIO(flac_covers[0].data)) elif ext in [".mp4", ".m4a", ".aac"]: mp4_tags: mp4.MP4 = mp4.MP4(filepath) - mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType] if mp4_covers: artwork = Image.open(BytesIO(mp4_covers[0])) if artwork: @@ -780,25 +795,17 @@ def _blender(filepath: Path) -> Image.Image | None: ) im: Image.Image | None = None try: - blend_image = blend_thumb(str(filepath)) - - bg = Image.new("RGB", blend_image.size, color=bg_color) - bg.paste(blend_image, mask=blend_image.getchannel(3)) - im = bg - - except ( - AttributeError, - UnidentifiedImageError, - TypeError, - ) as e: - if str(e) == "expected string or buffer": + if (blend_image := blend_thumb(str(filepath))) is not None: + bg = Image.new("RGB", blend_image.size, color=bg_color) + bg.paste(blend_image, mask=blend_image.getchannel(3)) + im = bg + else: logger.info( f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " - f"Doesn't have an embedded thumbnail. ({type(e).__name__})" + "Doesn't have an embedded thumbnail." ) - - else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @staticmethod @@ -906,15 +913,7 @@ def _epub_cover(filepath: Path, ext: str) -> Image.Image | None: """ im: Image.Image | None = None try: - archiver: _Archive_T = zipfile.ZipFile - if ext == ".cb7": - archiver = _SevenZipFile - elif ext == ".cbr": - archiver = rarfile.RarFile - elif ext == ".cbt": - archiver = _TarFile - - with archiver(filepath, "r") as archive: + with ThumbRenderer.__open_archive(filepath, ext) as archive: if "ComicInfo.xml" in archive.namelist(): comic_info = ET.fromstring(archive.read("ComicInfo.xml")) im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover") @@ -924,13 +923,7 @@ def _epub_cover(filepath: Path, ext: str) -> Image.Image | None: ) if not im: - for file_name in archive.namelist(): - if file_name.lower().endswith( - (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") - ): - image_data = archive.read(file_name) - im = Image.open(BytesIO(image_data)) - break + im = ThumbRenderer.__first_image(archive) except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) @@ -962,6 +955,63 @@ def __cover_from_comic_info( return im + @staticmethod + def _archive_thumb(filepath: Path, ext: str) -> Image.Image | None: + """Extract the first image found in the archive. + + Args: + filepath (Path): The path to the archive. + ext (str): The file extension. + + Returns: + Image: The first image found in the archive. + """ + im: Image.Image | None = None + try: + with ThumbRenderer.__open_archive(filepath, ext) as archive: + im = ThumbRenderer.__first_image(archive) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + return im + + @staticmethod + def __open_archive(filepath: Path, ext: str) -> _Archive: + """Open an archive with its corresponding archiver. + + Args: + filepath (Path): The path to the archive. + ext (str): The file extension. + + Returns: + _Archive: The opened archive. + """ + archiver: type[_Archive] = zipfile.ZipFile + if ext in {".7z", ".cb7", ".s7z"}: + archiver = _SevenZipFile + elif ext in {".cbr", ".rar"}: + archiver = rarfile.RarFile + elif ext in {".cbt", ".tar", ".tgz"}: + archiver = _TarFile + return archiver(filepath, "r") + + @staticmethod + def __first_image(archive: _Archive) -> Image.Image | None: + """Find and extract the first renderable image in the archive. + + Args: + archive (_Archive): The current archive. + + Returns: + Image: The first renderable image in the archive. + """ + for file_name in archive.namelist(): + if file_name.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): + image_data = archive.read(file_name) + return Image.open(BytesIO(image_data)) + + return None + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None: """Render a small font preview ("Aa") thumbnail from a font file. @@ -1044,7 +1094,7 @@ def _font_long_thumb(filepath: Path, size: int) -> Image.Image | None: font = ImageFont.truetype(filepath, size=font_size) text_wrapped: str = wrap_full_text( FONT_SAMPLE_TEXT, - font=font, # pyright: ignore[reportArgumentType] + font=font, width=size, draw=draw, ) @@ -1076,8 +1126,8 @@ def _image_raw_thumb(filepath: Path) -> Image.Image | None: ) except ( DecompressionBombError, - rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] + LibRawIOError, + LibRawFileUnsupportedError, ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @@ -1093,6 +1143,7 @@ def _image_exr_thumb(filepath: Path) -> Image.Image | None: try: # Load the EXR data to an array and rotate the color space from BGRA -> RGBA raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED) + assert raw_array is not None raw_array[..., :3] = raw_array[..., 2::-1] # Correct the gamma of the raw array @@ -1165,7 +1216,7 @@ def _image_vector_thumb(filepath: Path, size: int) -> Image.Image: # Write the image to a buffer as png buffer: QBuffer = QBuffer() buffer.open(QBuffer.OpenModeFlag.ReadWrite) - q_image.save(buffer, "PNG") # type: ignore[call-overload] + q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType] # Load the image from the buffer im = Image.new("RGB", (size, size), color="#1e1e1e") @@ -1214,7 +1265,7 @@ def get_image(path: str) -> Image.Image | None: return im @staticmethod - def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: + def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: # pyright: ignore[reportUnusedParameter] """Render a thumbnail for an STL file. Args: @@ -1287,7 +1338,7 @@ def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None: buffer: QBuffer = QBuffer() buffer.open(QBuffer.OpenModeFlag.ReadWrite) try: - q_image.save(buffer, "PNG") # type: ignore # pyright: ignore + q_image.save(buffer, "PNG") # pyright: ignore im = Image.open(BytesIO(buffer.buffer().data())) finally: buffer.close() @@ -1377,6 +1428,113 @@ def _video_thumb(filepath: Path) -> Image.Image | None: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im + @staticmethod + def _mdp_thumb(filepath: Path) -> Image.Image | None: + """Extract the thumbnail from a .mdp file. + + Args: + filepath (Path): The path of the .mdp file. + + Returns: + Image: The embedded thumbnail. + """ + im: Image.Image | None = None + try: + with open(filepath, "rb") as f: + magic = struct.unpack("<7sx", f.read(8))[0] + if magic != b"mdipack": + return im + + bin_header = struct.unpack(" Image.Image | None: + """Extract the base64-encoded thumbnail from a .pdn file header. + + Args: + filepath (Path): The path of the .pdn file. + + Returns: + Image: the decoded PNG thumbnail or None by default. + """ + im: Image.Image | None = None + with open(filepath, "rb") as f: + try: + # First 4 bytes are the magic number + if f.read(4) != b"PDN3": + return im + + # Header length is a little-endian 24-bit int + header_size = struct.unpack(" Image.Image | None: + """Extract the thumbnail from the SQLite database embedded in a .clip file. + + Args: + filepath (Path): The path of the .clip file. + + Returns: + Image: The embedded thumbnail, if extractable. + """ + im: Image.Image | None = None + try: + with open(filepath, "rb") as f: + blob = f.read() + sqlite_index = blob.find(b"SQLite format 3") + if sqlite_index == -1: + return im + + with sqlite3.connect(":memory:") as conn: + conn.deserialize(blob[sqlite_index:]) + thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone() + if thumbnail: + im = Image.open(BytesIO(thumbnail[0])) + conn.close() + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + + return im + def render( self, timestamp: float, @@ -1390,7 +1548,7 @@ def render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. base_size (tuple[int,int]): The unmodified base size of the thumbnail. pixel_ratio (float): The screen pixel ratio. @@ -1463,6 +1621,7 @@ def render_ignored( def fetch_cached_image(file_name: Path): image: Image.Image | None = None + assert self.driver.cache_manager is not None cached_path = self.driver.cache_manager.get_file_path(file_name) if cached_path and cached_path.is_file(): @@ -1503,7 +1662,7 @@ def fetch_cached_image(file_name: Path): save_to_file=file_name, ) - # If the normal renderer failed, fallback the the defaults + # If the normal renderer failed, fallback the defaults # (with native non-cached sizing!) if not image: image = ( @@ -1600,7 +1759,7 @@ def _render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. base_size (tuple[int,int]): The unmodified base size of the thumbnail. pixel_ratio (float): The screen pixel ratio. @@ -1627,6 +1786,11 @@ def _render( ext, MediaCategories.KRITA_TYPES, mime_fallback=True ): image = self._krita_thumb(_filepath) + # Clip Studio Paint ============================================ + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES + ): + image = self._clip_thumb(_filepath) # VTF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True @@ -1703,6 +1867,15 @@ def _render( ext, MediaCategories.PDF_TYPES, mime_fallback=True ): image = self._pdf_thumb(_filepath, adj_size) + # Archives ===================================================== + elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES): + image = self._archive_thumb(_filepath, ext) + # MDIPACK ====================================================== + elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES): + image = self._mdp_thumb(_filepath) + # Paint.NET ==================================================== + elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES): + image = self._pdn_thumb(_filepath) # No Rendered Thumbnail ======================================== if not image: raise NoRendererError @@ -1711,13 +1884,15 @@ def _render( image = self._resize_image(image, (adj_size, adj_size)) if save_to_file and savable_media_type and image: + assert self.driver.cache_manager is not None self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") except ( - UnidentifiedImageError, + AssertionError, + ChildProcessError, DecompressionBombError, + UnidentifiedImageError, ValueError, - ChildProcessError, ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) image = None diff --git a/src/tagstudio/qt/previews/vendored/blender_renderer.py b/src/tagstudio/qt/previews/vendored/blender_renderer.py index 012c1503d..1d83f13fd 100644 --- a/src/tagstudio/qt/previews/vendored/blender_renderer.py +++ b/src/tagstudio/qt/previews/vendored/blender_renderer.py @@ -1,22 +1,7 @@ #!/usr/bin/env python3 +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### # @@ -32,7 +17,7 @@ from PIL import Image, ImageOps -def blend_extract_thumb(path): +def blend_extract_thumb(path) -> tuple[bytes | None, int, int]: rend = b"REND" test = b"TEST" @@ -97,8 +82,10 @@ def blend_extract_thumb(path): return image_buffer, x, y -def blend_thumb(file_in): +def blend_thumb(file_in) -> Image.Image | None: buf, width, height = blend_extract_thumb(file_in) + if buf is None: + return None image = Image.frombuffer( "RGBA", (width, height), diff --git a/src/tagstudio/qt/previews/vendored/ffmpeg.py b/src/tagstudio/qt/previews/vendored/ffmpeg.py index e2d9a6c75..84d6ba196 100644 --- a/src/tagstudio/qt/previews/vendored/ffmpeg.py +++ b/src/tagstudio/qt/previews/vendored/ffmpeg.py @@ -1,7 +1,9 @@ -# Copyright (C) 2022 Karl Kroening (kkroening). -# Licensed under the GPL-3.0 License. +# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening) +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only # Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803 + import contextlib import json import os diff --git a/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py b/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py index 2baad0762..e3c05233f 100644 --- a/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py +++ b/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py @@ -1,6 +1,6 @@ -# type: ignore -# Copyright (C) 2022 James Robert (jiaaro). -# Licensed under the MIT License. +# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro) +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only # Vendored from pydub diff --git a/src/tagstudio/qt/previews/vendored/pydub/utils.py b/src/tagstudio/qt/previews/vendored/pydub/utils.py index 4a4cb8894..abc8ce9f3 100644 --- a/src/tagstudio/qt/previews/vendored/pydub/utils.py +++ b/src/tagstudio/qt/previews/vendored/pydub/utils.py @@ -1,3 +1,9 @@ +# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only +# Vendored from pydub + + import json import re import subprocess diff --git a/src/tagstudio/qt/resource_manager.py b/src/tagstudio/qt/resource_manager.py index e3d259b22..f1d387ab6 100644 --- a/src/tagstudio/qt/resource_manager.py +++ b/src/tagstudio/qt/resource_manager.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path diff --git a/src/tagstudio/qt/resources.qrc b/src/tagstudio/qt/resources.qrc index c425750b0..fcaf44c88 100644 --- a/src/tagstudio/qt/resources.qrc +++ b/src/tagstudio/qt/resources.qrc @@ -1,3 +1,5 @@ + + @@ -9,4 +11,4 @@ - \ No newline at end of file + diff --git a/src/tagstudio/qt/resources_rc.py b/src/tagstudio/qt/resources_rc.py index 6e1940b3a..fc5143759 100644 --- a/src/tagstudio/qt/resources_rc.py +++ b/src/tagstudio/qt/resources_rc.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + # Resource object code (Python 3) # Created by: object code # Created by: The Resource Compiler for Qt version 6.8.0 diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 27ad31145..42fac6790 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -1,9 +1,14 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + import math import time +from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING, Any, override -from PySide6.QtCore import QPoint, QRect, QSize +from PySide6.QtCore import QPoint, QRect, QSize, Signal from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea @@ -19,6 +24,9 @@ class ThumbGridLayout(QLayout): + # Id of first visible entry + visible_changed = Signal(int) + def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: super().__init__(None) self.driver: QtDriver = driver @@ -26,10 +34,6 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: self._item_thumbs: list[ItemThumb] = [] self._items: list[QLayoutItem] = [] - # Entry.id -> _entry_ids[index] - self._selected: dict[int, int] = {} - # _entry_ids[index] - self._last_selected: int | None = None self._entry_ids: list[int] = [] self._entries: dict[int, Entry] = {} @@ -47,12 +51,14 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: # _entry_ids[StartIndex:EndIndex] self._last_page_update: tuple[int, int] | None = None + self._scroll_to: int | None = None + + def scroll_to(self, entry_id: int): + self._scroll_to = entry_id + def set_entries(self, entry_ids: list[int]): self.scroll_area.verticalScrollBar().setValue(0) - self._selected.clear() - self._last_selected = None - self._entry_ids = entry_ids self._entries.clear() self._tag_entries.clear() @@ -83,90 +89,20 @@ def set_entries(self, entry_ids: list[int]): self._last_page_update = None - def select_all(self): - self._selected.clear() - for index, id in enumerate(self._entry_ids): - self._selected[id] = index - self._last_selected = index - - for entry_id in self._entry_items: - self._set_selected(entry_id) - - def select_inverse(self): - selected = {} - for index, id in enumerate(self._entry_ids): - if id not in self._selected: - selected[id] = index - self._last_selected = index - - for id in self._selected: - if id not in selected: - self._set_selected(id, value=False) - for id in selected: - self._set_selected(id) - - self._selected = selected - - def select_entry(self, entry_id: int): - if entry_id in self._selected: - index = self._selected.pop(entry_id) - if index == self._last_selected: - self._last_selected = None - self._set_selected(entry_id, value=False) - else: - try: - index = self._entry_ids.index(entry_id) - except ValueError: - index = -1 - - self._selected[entry_id] = index - self._last_selected = index - self._set_selected(entry_id) - - def select_to_entry(self, entry_id: int): - index = self._entry_ids.index(entry_id) - if len(self._selected) == 0: - self.select_entry(entry_id) - return - if self._last_selected is None: - self._last_selected = min(self._selected.values(), key=lambda i: abs(index - i)) - - start = self._last_selected - self._last_selected = index - - if start > index: - index, start = start, index - else: - index += 1 - - for i in range(start, index): - entry_id = self._entry_ids[i] - self._selected[entry_id] = i - self._set_selected(entry_id) - - def clear_selected(self): - for entry_id in self._entry_items: - self._set_selected(entry_id, value=False) + def update_selected(self): + for item_thumb in self._item_thumbs: + value = item_thumb.item_id in self.driver._selected + item_thumb.thumb_button.set_selected(value) - self._selected.clear() - self._last_selected = None - - def _set_selected(self, entry_id: int, value: bool = True): - if entry_id not in self._entry_items: - return - index = self._entry_items[entry_id] - if index < len(self._item_thumbs): - self._item_thumbs[index].thumb_button.set_selected(value) - - def add_tags(self, entry_ids: list[int], tag_ids: list[int]): + def add_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]): for tag_id in tag_ids: self._tag_entries.setdefault(tag_id, set()).update(entry_ids) - def remove_tags(self, entry_ids: list[int], tag_ids: list[int]): + def remove_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]): for tag_id in tag_ids: self._tag_entries.setdefault(tag_id, set()).difference_update(entry_ids) - def _fetch_entries(self, ids: list[int]): + def _fetch_entries(self, ids: Iterable[int]): ids = [id for id in ids if id not in self._entries] entries = self.driver.lib.get_entries(ids) for entry in entries: @@ -263,12 +199,24 @@ def setGeometry(self, arg__1: QRect) -> None: per_row, width_offset, height_offset = self._size(rect.right()) view_height = self.parentWidget().parentWidget().height() offset = self.scroll_area.verticalScrollBar().value() + if self._scroll_to is not None: + try: + index = self._entry_ids.index(self._scroll_to) + value = (index // per_row) * height_offset + self.scroll_area.verticalScrollBar().setMaximum(value) + self.scroll_area.verticalScrollBar().setSliderPosition(value) + offset = value + except ValueError: + pass + self._scroll_to = None visible_rows = math.ceil((view_height + (offset % height_offset)) / height_offset) offset = int(offset / height_offset) start = offset * per_row end = start + (visible_rows * per_row) + self.visible_changed.emit(self._entry_ids[start]) + # Load closest off screen rows start -= per_row * 3 end += per_row * 3 @@ -363,7 +311,7 @@ def setGeometry(self, arg__1: QRect) -> None: entry_id = self._entry_ids[i] item_index = self._entry_items[entry_id] item_thumb = self._item_thumbs[item_index] - item_thumb.thumb_button.set_selected(entry_id in self._selected) + item_thumb.thumb_button.set_selected(entry_id in self.driver._selected) item_thumb.assign_badge(BadgeType.ARCHIVED, entry_id in self._tag_entries[TAG_ARCHIVED]) item_thumb.assign_badge(BadgeType.FAVORITE, entry_id in self._tag_entries[TAG_FAVORITE]) @@ -383,7 +331,7 @@ def hasHeightForWidth(self) -> bool: @override def itemAt(self, index: int) -> QLayoutItem: if index >= len(self._items): - return None + return None # pyright: ignore[reportReturnType] return self._items[index] @override diff --git a/src/tagstudio/qt/translations.py b/src/tagstudio/qt/translations.py index 0d9983a62..bec0b194d 100644 --- a/src/tagstudio/qt/translations.py +++ b/src/tagstudio/qt/translations.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + from collections import defaultdict from pathlib import Path from platform import system @@ -39,6 +43,14 @@ "Viossa": "qpv", } +# A map of field class names to their respective translation keys. +FIELD_TYPE_KEYS = { + "DatetimeField": "field_type.datetime", + "DatetimeFieldTemplate": "field_type.datetime", + "TextField": "field_type.text", + "TextFieldTemplate": "field_type.text", +} + class Translator: _default_strings: dict[str, str] diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 34dec16fa..edb3cafa5 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + # SIGTERM handling based on the implementation by Virgil Dupras for dupeGuru: # https://github.com/arsenetar/dupeguru/blob/master/run.py#L71 @@ -8,7 +8,6 @@ """A Qt driver for TagStudio.""" -import contextlib import ctypes import math import os @@ -17,10 +16,11 @@ import sys import time from argparse import Namespace +from collections import OrderedDict from pathlib import Path from queue import Queue from shutil import which -from typing import Generic, TypeVar +from typing import TypeVar from warnings import catch_warnings import structlog @@ -51,10 +51,8 @@ from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption from tagstudio.core.library.alchemy.enums import ( BrowsingState, - FieldTypeEnum, SortingModeEnum, ) -from tagstudio.core.library.alchemy.fields import FieldID from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import Ignore @@ -62,7 +60,7 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.query_lang.util import ParsingError from tagstudio.core.ts_core import TagStudioCore -from tagstudio.core.utils.str_formatting import strip_web_protocol +from tagstudio.core.utils.str_formatting import is_version_outdated from tagstudio.core.utils.types import unwrap from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox @@ -71,6 +69,7 @@ from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow +from tagstudio.qt.controllers.out_of_date_message_box import OutOfDateMessageBox from tagstudio.qt.global_settings import ( DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, @@ -148,7 +147,7 @@ def run(self): # | A [B]<- C | # |[A]<- B C | Previous routes still exist # | A ->[D] | Stack is cut from [:A] on new route -class History(Generic[T]): +class History[T]: __history: list[T] __index: int = 0 @@ -194,7 +193,7 @@ class QtDriver(DriverMixin, QObject): applied_theme: Theme lib: Library - cache_manager: CacheManager + cache_manager: CacheManager | None browsing_history: History[BrowsingState] @@ -205,7 +204,8 @@ def __init__(self, args: Namespace): self.lib = Library() self.rm: ResourceManager = ResourceManager() self.args = args - self.frame_content: list[int] = [] # List of Entry IDs on the current page + self.frame_content: list[int] = [] # List of Entry IDs for the current query + self._selected: OrderedDict[int, None] = OrderedDict() self.pages_count = 0 self.scrollbar_pos = 0 @@ -257,7 +257,13 @@ def __init__(self, args: Namespace): @property def selected(self) -> list[int]: - return list(self.main_window.thumb_layout._selected.keys()) + return list(self._selected.keys()) + + @property + def last_selected(self) -> int | None: + if len(self._selected) == 0: + return None + return reversed(self._selected).__next__() def __reset_navigation(self) -> None: self.browsing_history = History(BrowsingState.show_all()) @@ -297,13 +303,13 @@ def start(self) -> None: sys.argv += ["-platform", "windows:darkmode=2"] self.app = QApplication(sys.argv) self.app.setStyle("Fusion") - if self.settings.theme == Theme.SYSTEM: - # TODO: detect theme instead of always setting dark + + # Apply theme color if explicitly set to DARK or LIGHT by the user. + # For SYSTEM, we let Qt decide based on OS theme. + if self.settings.theme == Theme.DARK: self.app.styleHints().setColorScheme(Qt.ColorScheme.Dark) - else: - self.app.styleHints().setColorScheme( - Qt.ColorScheme.Dark if self.settings.theme == Theme.DARK else Qt.ColorScheme.Light - ) + elif self.settings.theme == Theme.LIGHT: + self.app.styleHints().setColorScheme(Qt.ColorScheme.Light) if ( platform.system() == "Darwin" or platform.system() == "Windows" @@ -344,7 +350,7 @@ def start(self) -> None: if os.name == "nt": appid = "cyanvoxel.tagstudio.9" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore[attr-defined,unused-ignore] + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) self.app.setApplicationName("tagstudio") self.app.setApplicationDisplayName("TagStudio") @@ -532,7 +538,7 @@ def create_dupe_files_modal(): # TODO: Move this to a settings screen. self.main_window.menu_bar.clear_thumb_cache_action.triggered.connect( - lambda: self.cache_manager.clear_cache() + lambda: unwrap(self.cache_manager).clear_cache() ) # endregion @@ -563,6 +569,16 @@ def create_about_modal(): self.main_window.search_field.textChanged.connect(self.update_completions_list) + def on_visible_changed(entry_id: int | None): + current = self.browsing_history.current + page_index = current.page_index + if entry_id is None: + current.page_positions.pop(page_index) + else: + current.page_positions[page_index] = entry_id + + self.main_window.thumb_layout.visible_changed.connect(on_visible_changed) + self.archived_updated.connect( lambda hidden: self.update_badges( {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False @@ -579,7 +595,7 @@ def create_about_modal(): ) self.init_library_window() - self.migration_modal: JsonMigrationModal = None + self.migration_modal: JsonMigrationModal | None = None path_result = self.evaluate_path(str(self.args.open).lstrip().rstrip()) if path_result.success and path_result.library_path: @@ -594,6 +610,10 @@ def create_about_modal(): if not which(FFMPEG_CMD) or not which(FFPROBE_CMD): FfmpegMissingMessageBox().show() + latest_version = TagStudioCore.get_most_recent_release_version() + if latest_version and is_version_outdated(VERSION, latest_version): + OutOfDateMessageBox().exec() + self.app.exec() self.shutdown() @@ -754,6 +774,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.setWindowTitle(self.base_title) self.frame_content.clear() + self._selected.clear() if self.color_manager_panel: self.color_manager_panel.reset() @@ -848,7 +869,7 @@ def add_tag_action_callback(self): def select_all_action_callback(self): """Set the selection to all visible items.""" - self.main_window.thumb_layout.select_all() + self.select_all() self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -857,7 +878,7 @@ def select_all_action_callback(self): def select_inverse_action_callback(self): """Invert the selection of all visible items.""" - self.main_window.thumb_layout.select_inverse() + self.select_inverse() self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -865,7 +886,7 @@ def select_inverse_action_callback(self): self.main_window.preview_panel.set_selection(self.selected, update_preview=False) def clear_select_action_callback(self): - self.main_window.thumb_layout.clear_selected() + self.clear_selected() self.set_select_actions_visibility() self.set_clipboard_menu_viability() @@ -875,7 +896,7 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): selected: list[int] = self.selected self.main_window.thumb_layout.add_tags(selected, tag_ids) self.lib.add_tags_to_entries(selected, tag_ids) - self.emit_badge_signals(tag_ids) + self.emit_badge_signals(tag_ids, emit_on_absent=False) def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): """Callback to send on or more files to the system trash. @@ -891,21 +912,21 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = origin_id(id): The entry ID associated with the widget making the call. """ entry: Entry | None = None - pending: list[tuple[int, Path]] = [] + pending: list[tuple[int | None, Path]] = [] deleted_count: int = 0 selected = self.selected + library_dir = unwrap(self.lib.library_dir) if len(selected) <= 1 and origin_path: origin_id_ = origin_id - if not origin_id_: - with contextlib.suppress(IndexError): - origin_id_ = selected[0] + if origin_id_ is None: + origin_id_ = selected[0] if len(selected) > 0 else None pending.append((origin_id_, Path(origin_path))) - elif (len(selected) > 1) or (len(selected) <= 1): + else: for item in selected: - entry = self.lib.get_entry(item) + entry = unwrap(self.lib.get_entry(item)) filepath: Path = entry.path pending.append((item, filepath)) @@ -920,39 +941,31 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = e_id, f = tup if (origin_path == f) or (not origin_path): self.main_window.preview_panel.preview_thumb.media_player.stop() - if delete_file(self.lib.library_dir / f): - self.main_window.status_bar.showMessage( - Translations.format( - "status.deleting_file", i=i, count=len(pending), path=f - ) - ) - self.main_window.status_bar.repaint() - self.lib.remove_entries([e_id]) + msg = Translations.format( + "status.deleting_file", i=i, count=len(pending), path=f + ) + self.main_window.status_bar.showMessage(msg) + self.main_window.status_bar.repaint() + + if e_id is not None: + self.lib.remove_entries([e_id]) + if delete_file(library_dir / f): deleted_count += 1 - selected.clear() - self.clear_select_action_callback() - - if deleted_count > 0: - self.update_browsing_state() - self.main_window.preview_panel.set_selection(selected) - - if len(selected) <= 1 and deleted_count == 0: - self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) - elif len(selected) <= 1 and deleted_count == 1: - self.main_window.status_bar.showMessage( - Translations.format("status.deleted_file_plural", count=deleted_count) - ) - elif len(selected) > 1 and deleted_count == 0: - self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) - elif len(selected) > 1 and deleted_count < len(selected): - self.main_window.status_bar.showMessage( - Translations.format("status.deleted_partial_warning", count=deleted_count) - ) - elif len(selected) > 1 and deleted_count == len(selected): - self.main_window.status_bar.showMessage( - Translations.format("status.deleted_file_plural", count=deleted_count) - ) + + self.clear_select_action_callback() + self.update_browsing_state() + + if deleted_count > 0 and deleted_count != len(pending): + msg = Translations.format("status.deleted_partial_warning", count=deleted_count) + else: + index = min(deleted_count, 2) + msg = ( + Translations["status.deleted_none"], + Translations["status.deleted_file_singular"], + Translations.format("status.deleted_file_plural", count=deleted_count), + )[index] + self.main_window.status_bar.showMessage(msg) self.main_window.status_bar.repaint() def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int: @@ -1087,7 +1100,7 @@ def add_new_files_runnable(self, tracker: RefreshTracker): pw.hide(), pw.deleteLater(), # refresh the library only when new items are added - files_count and self.update_browsing_state(), # type: ignore + files_count and self.update_browsing_state(), ) ) QThreadPool.globalInstance().start(r) @@ -1107,8 +1120,7 @@ def run_macros(self, name: MacroID, entry_ids: list[int]): def run_macro(self, name: MacroID, entry_id: int): """Run a specific Macro on an Entry given a Macro name.""" - entry: Entry = self.lib.get_entry(entry_id) - full_path = self.lib.library_dir / entry.path + entry: Entry = unwrap(self.lib.get_entry(entry_id)) source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower() logger.info( @@ -1125,32 +1137,6 @@ def run_macro(self, name: MacroID, entry_id: int): continue self.run_macro(macro_id, entry_id) - elif name == MacroID.SIDECAR: - parsed_items = TagStudioCore.get_gdl_sidecar(full_path, source) - for field_id, value in parsed_items.items(): - if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str): - value = self.lib.tag_from_strings(value) - self.lib.add_field_to_entry( - entry.id, - field_id=field_id, - value=value, - ) - - elif name == MacroID.BUILD_URL: - url = TagStudioCore.build_url(entry, source) - if url is not None: - self.lib.add_field_to_entry(entry.id, field_id=FieldID.SOURCE, value=url) - elif name == MacroID.MATCH: - TagStudioCore.match_conditions(self.lib, entry.id) - elif name == MacroID.CLEAN_URL: - for field in entry.text_fields: - if field.type.type == FieldTypeEnum.TEXT_LINE and field.value: - self.lib.update_entry_field( - entry_ids=entry.id, - field=field, - content=strip_web_protocol(field.value), - ) - def sorting_direction_callback(self): logger.info("Sorting Direction Changed", ascending=self.main_window.sorting_direction) self.update_browsing_state( @@ -1199,16 +1185,16 @@ def mouse_navigation(self, event: QMouseEvent): def page_move(self, value: int, absolute=False) -> None: logger.info("page_move", value=value, absolute=absolute) + current = self.browsing_history.current if not absolute: - value += self.browsing_history.current.page_index - - self.browsing_history.push( - self.browsing_history.current.with_page_index(clamp(value, 0, self.pages_count - 1)) - ) - - # TODO: Re-allow selecting entries across multiple pages at once. - # This works fine with additive selection but becomes a nightmare with bridging. + current.page_index += value + else: + current.page_index = value + current.page_index = clamp(current.page_index, 0, self.pages_count - 1) + # TODO: The back mouse button will no longer move to the previous page and + # instead goto the previous query passing a new state to update_browsing_state + # will get this behaviour back but would mess with persisting page scroll positions self.update_browsing_state() def navigation_callback(self, delta: int) -> None: @@ -1239,10 +1225,10 @@ def paste_fields_action_callback(self): for field in self.copy_buffer["fields"]: exists = False for e in existing_fields: - if field.type_key == e.type_key and field.value == e.value: + if field == e: exists = True if not exists: - self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value) + self.lib.add_field_to_entries(id, field=field) self.lib.add_tags_to_entries(id, self.copy_buffer["tags"]) if len(self.selected) > 1: if TAG_ARCHIVED in self.copy_buffer["tags"]: @@ -1269,12 +1255,12 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """ logger.info("[QtDriver] Selecting Items:", item_id=item_id, append=append, bridge=bridge) if append: - self.main_window.thumb_layout.select_entry(item_id) + self.select_entry(item_id) elif bridge: - self.main_window.thumb_layout.select_to_entry(item_id) + self.select_to_entry(item_id) else: - self.main_window.thumb_layout.clear_selected() - self.main_window.thumb_layout.select_entry(item_id) + self.clear_selected() + self.select_entry(item_id) self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -1389,7 +1375,14 @@ def update_thumbs(self): self.thumb_job_queue.all_tasks_done.notify_all() self.thumb_job_queue.not_full.notify_all() - self.main_window.thumb_layout.set_entries(self.frame_content) + page_size = ( + len(self.frame_content) if self.settings.infinite_scroll else self.settings.page_size + ) + page = self.browsing_history.current.page_index + start = page * page_size + end = min(start + page_size, len(self.frame_content)) + + self.main_window.thumb_layout.set_entries(self.frame_content[start:end]) self.main_window.thumb_layout.update() self.main_window.update() @@ -1404,8 +1397,11 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add add_tags(bool): Flag determining if tags associated with the badges need to be added to the items. Defaults to True. """ - item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id] - pending_entries: dict[BadgeType, list[int]] = {} + entry_ids = ( + set(self._selected.keys()) + if (origin_id == 0 or origin_id in self._selected) + else {origin_id} + ) logger.info( "[QtDriver][update_badges] Updating ItemThumb badges", @@ -1414,12 +1410,9 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add add_tags=add_tags, ) for it in self.main_window.thumb_layout._item_thumbs: - if it.item_id in item_ids: + if it.item_id in entry_ids: for badge_type, value in badge_values.items(): if add_tags: - if not pending_entries.get(badge_type): - pending_entries[badge_type] = [] - pending_entries[badge_type].append(it.item_id) it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type]) it.assign_badge(badge_type, value) @@ -1428,10 +1421,9 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add logger.info( "[QtDriver][update_badges] Adding tags to updated entries", - pending_entries=pending_entries, + pending_entries=entry_ids, ) for badge_type, value in badge_values.items(): - entry_ids = pending_entries.get(badge_type, []) tag_ids = [BADGE_TAGS[badge_type]] if value: @@ -1459,8 +1451,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: # search the library start_time = time.time() Ignore.get_patterns(self.lib.library_dir, include_global=True) - page_size = 0 if self.settings.infinite_scroll else self.settings.page_size - results = self.lib.search_library(self.browsing_history.current, page_size) + results = self.lib.search_library(self.browsing_history.current, page_size=0) logger.info("items to render", count=len(results)) end_time = time.time() @@ -1475,9 +1466,17 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: # update page content self.frame_content = results.ids + page_index = self.browsing_history.current.page_index + if state is None: + entry_id = self.browsing_history.current.page_positions.get(page_index) + else: + entry_id = self.last_selected + if entry_id is not None: + self.main_window.thumb_layout.scroll_to(entry_id) self.update_thumbs() # update pagination + page_size = 0 if self.settings.infinite_scroll else self.settings.page_size if page_size > 0: self.pages_count = math.ceil(results.total_count / page_size) else: @@ -1621,8 +1620,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): Ignore.get_patterns(self.lib.library_dir, include_global=True) self.__reset_navigation() - # TODO - make this call optional - if self.lib.entries_count < 10000: + if self.settings.scan_files_on_open: self.add_new_files_callback() if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS: @@ -1693,3 +1691,45 @@ def drag_move_event(self, event: QDragMoveEvent): event.accept() else: event.ignore() + + def select_all(self): + self._selected = OrderedDict.fromkeys(self.frame_content) + self.main_window.thumb_layout.update_selected() + + def select_inverse(self): + selected = OrderedDict() + for id in self.frame_content: + if id not in self._selected: + selected[id] = None + + self._selected = selected + self.main_window.thumb_layout.update_selected() + + def select_entry(self, entry_id: int): + if entry_id in self._selected: + self._selected.pop(entry_id) + else: + self._selected[entry_id] = None + self.main_window.thumb_layout.update_selected() + + def select_to_entry(self, entry_id: int): + if len(self._selected) == 0: + self.select_entry(entry_id) + return + last_selected = reversed(self._selected).__next__() + start = self.frame_content.index(last_selected) + end = self.frame_content.index(entry_id) + + if start > end: + end, start = start, end + else: + end += 1 + + for i in range(start, end): + entry_id = self.frame_content[i] + self._selected[entry_id] = None + self.main_window.thumb_layout.update_selected() + + def clear_selected(self): + self._selected.clear() + self.main_window.thumb_layout.update_selected() diff --git a/src/tagstudio/qt/utils/custom_runnable.py b/src/tagstudio/qt/utils/custom_runnable.py index 6520c16f4..06932bc7a 100644 --- a/src/tagstudio/qt/utils/custom_runnable.py +++ b/src/tagstudio/qt/utils/custom_runnable.py @@ -1,12 +1,11 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtCore import QObject, QRunnable, Signal -class CustomRunnable(QRunnable, QObject): +class CustomRunnable(QRunnable, QObject): # pyright: ignore[reportUnsafeMultipleInheritance] done = Signal() def __init__(self, function) -> None: diff --git a/src/tagstudio/qt/utils/file_deleter.py b/src/tagstudio/qt/utils/file_deleter.py index 1e264b075..d12d4a9d3 100644 --- a/src/tagstudio/qt/utils/file_deleter.py +++ b/src/tagstudio/qt/utils/file_deleter.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import os diff --git a/src/tagstudio/qt/utils/file_opener.py b/src/tagstudio/qt/utils/file_opener.py index e1b27c276..a009dcffc 100644 --- a/src/tagstudio/qt/utils/file_opener.py +++ b/src/tagstudio/qt/utils/file_opener.py @@ -1,6 +1,6 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import shutil import subprocess diff --git a/src/tagstudio/qt/utils/function_iterator.py b/src/tagstudio/qt/utils/function_iterator.py index 9f2137909..ef88d31e0 100644 --- a/src/tagstudio/qt/utils/function_iterator.py +++ b/src/tagstudio/qt/utils/function_iterator.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Callable diff --git a/src/tagstudio/qt/views/clickable_label.py b/src/tagstudio/qt/views/clickable_label.py index c7478b9f8..f382d14e0 100644 --- a/src/tagstudio/qt/views/clickable_label.py +++ b/src/tagstudio/qt/views/clickable_label.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import override diff --git a/src/tagstudio/qt/views/clickable_slider.py b/src/tagstudio/qt/views/clickable_slider.py index a683a74a3..d1ac82a1b 100644 --- a/src/tagstudio/qt/views/clickable_slider.py +++ b/src/tagstudio/qt/views/clickable_slider.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from typing import override diff --git a/src/tagstudio/qt/views/edit_text_box_modal.py b/src/tagstudio/qt/views/edit_text_box_modal.py index 3c3bce32b..fd60ba21e 100644 --- a/src/tagstudio/qt/views/edit_text_box_modal.py +++ b/src/tagstudio/qt/views/edit_text_box_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout diff --git a/src/tagstudio/qt/views/edit_text_line_modal.py b/src/tagstudio/qt/views/edit_text_line_modal.py index 5ee933ccd..d7ce6d0d8 100644 --- a/src/tagstudio/qt/views/edit_text_line_modal.py +++ b/src/tagstudio/qt/views/edit_text_line_modal.py @@ -1,5 +1,6 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from collections.abc import Callable diff --git a/src/tagstudio/qt/views/fix_ignored_modal_view.py b/src/tagstudio/qt/views/fix_ignored_modal_view.py index b198a2b43..fdad55e61 100644 --- a/src/tagstudio/qt/views/fix_ignored_modal_view.py +++ b/src/tagstudio/qt/views/fix_ignored_modal_view.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING, override diff --git a/src/tagstudio/qt/views/ignore_modal_view.py b/src/tagstudio/qt/views/ignore_modal_view.py index 7e7de161e..277f5eea6 100644 --- a/src/tagstudio/qt/views/ignore_modal_view.py +++ b/src/tagstudio/qt/views/ignore_modal_view.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import structlog diff --git a/src/tagstudio/qt/views/layouts/flow_layout.py b/src/tagstudio/qt/views/layouts/flow_layout.py index 14fb411da..af14a7b2e 100644 --- a/src/tagstudio/qt/views/layouts/flow_layout.py +++ b/src/tagstudio/qt/views/layouts/flow_layout.py @@ -1,6 +1,7 @@ -# Copyright (C) 2013 Riverbank Computing Limited. -# Copyright (C) 2022 The Qt Company Ltd. -# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause +# SPDX-FileCopyrightText: (C) 2013 Riverbank Computing Limited. +# SPDX-FileCopyrightText: (C) 2022 The Qt Company Ltd. +# SPDX-FileCopyrightText: (C) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only """PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x.""" diff --git a/src/tagstudio/qt/views/library_info_window_view.py b/src/tagstudio/qt/views/library_info_window_view.py index 694d9259d..bfd06c0ac 100644 --- a/src/tagstudio/qt/views/library_info_window_view.py +++ b/src/tagstudio/qt/views/library_info_window_view.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import math diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index a4c0485a5..374df6465 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import typing diff --git a/src/tagstudio/qt/views/paged_body_wrapper.py b/src/tagstudio/qt/views/paged_body_wrapper.py index cb713b3e9..a6380d547 100644 --- a/src/tagstudio/qt/views/paged_body_wrapper.py +++ b/src/tagstudio/qt/views/paged_body_wrapper.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtCore import Qt diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py index 241e57f73..b754c118e 100755 --- a/src/tagstudio/qt/views/panel_modal.py +++ b/src/tagstudio/qt/views/panel_modal.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Callable diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 9c0610745..d3a646480 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -1,5 +1,6 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import traceback import typing diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index 33bfd42e0..e159c4776 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -1,5 +1,6 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + import math import time diff --git a/src/tagstudio/qt/views/qbutton_wrapper.py b/src/tagstudio/qt/views/qbutton_wrapper.py index e26703d5e..c6f90e325 100644 --- a/src/tagstudio/qt/views/qbutton_wrapper.py +++ b/src/tagstudio/qt/views/qbutton_wrapper.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtWidgets import QPushButton diff --git a/src/tagstudio/qt/views/splash.py b/src/tagstudio/qt/views/splash.py index fe139d173..220c4c5c4 100644 --- a/src/tagstudio/qt/views/splash.py +++ b/src/tagstudio/qt/views/splash.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import math diff --git a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py b/src/tagstudio/qt/views/styles/rounded_pixmap_style.py index df83c5a0b..33395a5a7 100644 --- a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py +++ b/src/tagstudio/qt/views/styles/rounded_pixmap_style.py @@ -1,8 +1,6 @@ -# Based on the implementation by eyllanesc: -# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius -# Licensed under the Creative Commons CC BY-SA 4.0 License: -# https://creativecommons.org/licenses/by-sa/4.0/ -# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) 2019 Edwin Yllanes +# SPDX-License-Identifier: CC-BY-SA-4.0 +# See: https://stackoverflow.com/questions/54230005/qmovie-with-border-radius/54231484#54231484 from typing import override diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index 6ab4e0052..7dd002ac0 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -1,5 +1,5 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Iterable diff --git a/src/tagstudio/qt/views/thumb_button.py b/src/tagstudio/qt/views/thumb_button.py index 88f5f9067..6ab99ef33 100644 --- a/src/tagstudio/qt/views/thumb_button.py +++ b/src/tagstudio/qt/views/thumb_button.py @@ -1,6 +1,5 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import sys @@ -87,7 +86,7 @@ def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: ) @override - def paintEvent(self, arg__1: QPaintEvent) -> None: # type: ignore + def paintEvent(self, arg__1: QPaintEvent) -> None: super().paintEvent(arg__1) if self.hovered or self.selected: painter = QPainter() @@ -128,13 +127,13 @@ def paintEvent(self, arg__1: QPaintEvent) -> None: # type: ignore painter.end() @override - def enterEvent(self, event: QEnterEvent) -> None: # type: ignore + def enterEvent(self, event: QEnterEvent) -> None: self.hovered = True self.repaint() return super().enterEvent(event) @override - def leaveEvent(self, event: QEvent) -> None: # type: ignore + def leaveEvent(self, event: QEvent) -> None: self.hovered = False self.repaint() return super().leaveEvent(event) diff --git a/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license b/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license new file mode 100644 index 000000000..d56fe894e --- /dev/null +++ b/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium) +SPDX-License-Identifier: OFL-1.1 diff --git a/src/tagstudio/resources/translations/am.json b/src/tagstudio/resources/translations/am.json new file mode 100644 index 000000000..b889d582b --- /dev/null +++ b/src/tagstudio/resources/translations/am.json @@ -0,0 +1,46 @@ +{ + "about.config_path": "የመዋቅር መንገድ", + "about.description": "ታግስቱዲዮ የፎቶ እና ፋይል አደረጃጀት በ ታግ መሰረት ላይ የተሰራ ሲስተም ሲሆን ትኩረቱን ለተጠቃሚዎች ነፃነት እና እንደሁኔታዎች ለመቀያየር ምቹ የሆነ መተግበሪያ ነው። ባለቤት የሌላቸው ኮድ ወይም ፎርማት፣ ብዙ ትናንሽ ሜታ ዳታ ፋይል የማይፈጥር እና የፋይል አደረጃጀት ቅርፆ የማይቀይር ነው።", + "about.documentation": "ሰነዶች", + "about.license": "ፈቃድ", + "about.module.found": "ተገኘ", + "about.title": "ስለ ታግስቱዲዮ", + "about.website": "ድህረገፅ", + "app.git": "ጊት ኮሚት", + "app.pre_release": "ቅድመ መለቀቅ", + "app.title": "{base_title} - ላይብረሪ'{library_dir}", + "color.color_border": "ለድንበር ሁለተኛ ቀለም ተጠቀም", + "color.confirm_delete": "\"{color_name}\" ይህን ቀለም ለማጥፋት ወስነሃል?", + "color.delete": "ታግ አጥፋ", + "color.import_pack": "የቀለም እሽግ አስገባ", + "color.name": "ስም", + "color.namespace.delete.prompt": "ይህን የቀለም ምድብ ለማጥፋት ወስነሃል? ይህን ካጠፋህ ሁሉንም ቀለሞች ምድብ ውስጥ ያሉትን አንድ ላይ ያጠፋል!", + "color.namespace.delete.title": "አጥፋ የቀለም ምድብ", + "color.new": "አዲስ ቀለም", + "color.placeholder": "ቀለም", + "color.primary": "ዋና ቀለም", + "color.primary_required": "ዋና ቀለም (አስፈላጊ)", + "color.secondary": "ሁለተኛ ቀለም", + "color.title.no_color": "ቀለም አልባ", + "color_manager.title": "ታግ ቀለሞች አስተዳደር", + "dependency.missing.title": "{dependency} አልተገነኘም", + "drop_import.description": "እነዚህ ፋይሎች ከዚህ በፊት ላይብረሪ ውስጥ ካሉ የፋይል መንገድ ጋር ተመሳሳይነት አላቸው", + "drop_import.duplicates_choice.plural": "የሚከተለው {count} ፋይሎች ከዚህ በፊት ላይብረሪ ውስጥ ካሉ የፋይል መንገዶች ጋር ተመሳሳይ ናቸው።", + "drop_import.duplicates_choice.singular": "የሚከተለው ፋይል ከዚህ በፊት ላይብረሪ ፋይል መንገድ ጋር ተመሳሳይ ነው።", + "drop_import.progress.label.initial": "አድስ ፋይሎች ማስገባ ላይ...", + "drop_import.progress.label.plural": "አዲስ ፋይሎች በማስገባት ላይ...\n{count} የገቡ ፋይሎች።{suffix}", + "drop_import.progress.label.singular": "አዲስ ፋይሎች በማስገባት ላይ...\n1 ፋይል ገብቷል.{suffix}", + "drop_import.progress.window_title": "ፋይሎች አስገባ", + "drop_import.title": "የሚጋጩ ፋይል(ሎች)", + "edit.color_manager": "የታግ ቀለሞችን አስተዳድር", + "edit.copy_fields": "ኮፒ ፊልዶች", + "edit.paste_fields": "ፔስት ፊልድስ", + "edit.tag_manager": "ታጎች አስተዳድር", + "entries.duplicate.merge": "ተደጋጋሚ መረጃ አዋህድ", + "entries.duplicate.merge.label": "ተደጋጋሚ መረጃ ማዋሃድ ላይ...", + "entries.duplicate.refresh": "ተደጋጋሚ መረጃ ማደስ", + "entries.duplicates.description": "ተደጋጋሚ መረጃዎች ማለት ከአንድ በላይ መረጃዎች ዲስክ ላይ ወዳለ አንድ ፋይል የሚጠቁሙ ከሆነ ነው። እነዚህን ማዋሃድ ታጎችን እና ሜታዳታ ከሁሉ ተደጋጋዊ ፋይሎች ወደ አንድ ይቀይረዋል። ይህ ከ \"ተደጋጋሚ ፋይሎች\" ጋር አንድ አይደለም፤ ይህም ተደጋጋሚ ፋይሎች ከታግ ስቱዲዮ ውጪ የሆኑ ናቸው።", + "entries.generic.refresh_alt": "&ማደስ", + "entries.generic.remove.removing": "መረጃዎች ማጥፋት ላይ", + "entries.generic.remove.removing_count": "{count} መረጃዎች ማጥፋት ላይ ..." +} diff --git a/src/tagstudio/resources/translations/ceb.json b/src/tagstudio/resources/translations/ceb.json new file mode 100644 index 000000000..1866628b6 --- /dev/null +++ b/src/tagstudio/resources/translations/ceb.json @@ -0,0 +1,111 @@ +{ + "about.documentation": "Dokumentasyon", + "about.license": "Lisensiya", + "about.module.found": "Makit-i", + "about.title": "Mahitungod sa TagStudio", + "color.color_border": "Gamita ang Ikaduha nga Kolor alang sa Utlanan", + "color.import_pack": "Pag-angkat og Putos sa Kolor", + "color.name": "Ngalan", + "color.namespace.delete.title": "Panas-i ang Bansag sa Kolor", + "color.new": "Bag-o nga Kolor", + "color.placeholder": "Kolor", + "color.primary": "Nag-una nga Kolor", + "color.primary_required": "Nag-una nga Kolor (Kinahanglan)", + "color.secondary": "Ikaduha nga Kolor", + "color.title.no_color": "Walay Kolor", + "color_manager.title": "Pag-atiman ang Mga Kolor sa Timailhan", + "edit.color_manager": "Pag-atiman sa Mga Kolor sa Timailhan", + "entries.generic.refresh_alt": "Pag&lab-as", + "entries.tags": "Mga Timailhan", + "field.edit": "Usba ang Uma", + "file.dimensions": "Sukod", + "file.duration": "Gilay-on", + "generic.add": "Pagdugang", + "generic.apply": "Ibutang", + "generic.apply_alt": "I&butang", + "generic.cancel": "Paphai", + "generic.cancel_alt": "&Paphai", + "generic.close": "Tak-opi", + "generic.continue": "Padayon", + "generic.copy": "Hulari", + "generic.cut": "Hagbasi", + "generic.delete": "Panas-i", + "generic.delete_alt": "Pa&nas-i", + "generic.done": "Human na", + "generic.done_alt": "&Human na", + "generic.edit": "Usba", + "generic.edit_alt": "&Usba", + "generic.filename": "Ngalan sa limbas", + "generic.missing": "Nawala", + "generic.navigation.back": "Pagbalik", + "generic.navigation.next": "Pagpadayon", + "generic.no": "Dili", + "generic.none": "Wala", + "generic.overwrite": "Puliha", + "generic.overwrite_alt": "Pu&liha", + "generic.paste": "Pagbun-ag", + "generic.remove": "Tangtangi", + "generic.remove_alt": "&Tangtangi", + "generic.rename": "Ilisdi ang ngalan", + "generic.rename_alt": "Ilisdi ang &ngalan", + "generic.reset": "Pag-usab", + "generic.save": "Pagtipig", + "generic.skip": "Lab-aki", + "generic.skip_alt": "Lab-a&ki", + "generic.yes": "Oo", + "home.search": "Pagpangita", + "json_migration.heading.aliases": "Mga dagmay:", + "json_migration.heading.colors": "Mga kolor:", + "json_migration.heading.differ": "Sumpaki", + "json_migration.heading.match": "Mibagay", + "json_migration.heading.names": "Mga ngalan:", + "json_migration.heading.paths": "Mga dalan:", + "json_migration.heading.shorthands": "Mga laktod", + "library.name": "Librarya", + "library_info.cleanup": "Paghinlo", + "library_info.stats": "Estatistika", + "library_info.stats.colors": "Mga Kolor sa Timailhan", + "library_info.stats.entries": "Mga sulod:", + "library_info.stats.fields": "Mga uma:", + "library_info.stats.macros": "Mga makro:", + "library_info.stats.namespaces": "Mga bansag:", + "library_info.stats.tags": "Mga timailhan:", + "library_object.name": "Ngalan", + "media_player.autoplay": "Gawing modula", + "media_player.loop": "Paglakong", + "menu.edit": "Usba", + "menu.edit.new_tag": "Bag-o nga &Timailhan", + "menu.file": "Li&mbas", + "menu.help": "&Tabang", + "menu.help.about": "Mahitungod sa", + "menu.macros": "&Mga makro:", + "menu.select": "Pilia", + "menu.settings": "Mga Himutangan...", + "menu.tools": "Mga &himan", + "menu.view": "Paglanta&w", + "menu.window": "Tamboanan", + "namespace.create.title": "Paghimo og Bansag", + "namespace.new.button": "Bag-o nga Bansag", + "namespace.new.prompt": "Paghimo og Bag-o nga Bansag aron Makasugod og Dugang og", + "preview.ignored": "Linguglingogon", + "settings.dateformat.english": "Inggles", + "settings.dateformat.international": "Internasyonal", + "settings.dateformat.system": "Sistema", + "settings.language": "Pinulongan", + "settings.splash.option.random": "Sinalagma", + "settings.tag_click_action.open_edit": "Usba ang Timailhan", + "settings.theme.dark": "Dulom", + "settings.theme.label": "Hilisgotan:", + "settings.theme.light": "Hayag", + "settings.theme.system": "Sistema", + "settings.title": "Mga Himutangan", + "sorting.direction.ascending": "Nagasaka", + "sorting.direction.descending": "Naganaog", + "sorting.mode.random": "Sinalagma", + "status.library_version_expected": "Dinahom:", + "status.library_version_found": "Makit-i", + "status.results": "Mga agi", + "tag.choose_color": "Pamili og Kolor sa Timailhan", + "tag.color": "Kolor", + "tag.edit": "Usba ang Timailhan" +} diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 71bb1c8f3..22062b038 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -74,6 +74,9 @@ "field.copy": "Feld kopieren", "field.edit": "Feld bearbeiten", "field.paste": "Feld einfügen", + "field_type.datetime": "Datum - Uhrzeit", + "field_type.text": "Text", + "field_type.unknown": "Unbekannter Typ", "file.date_added": "Hinzufügungsdatum", "file.date_created": "Erstellungsdatum", "file.date_modified": "Datum geändert", @@ -140,6 +143,7 @@ "home.search_entries": "Nach Einträgen suchen", "home.search_library": "Bibliothek durchsuchen", "home.search_tags": "Tags suchen", + "home.show_hidden_entries": "Zeige versteckte Einträge an", "home.thumbnail_size": "Größe des Vorschaubildes", "home.thumbnail_size.extra_large": "Extra Große Vorschau", "home.thumbnail_size.large": "Große Vorschau", @@ -156,8 +160,6 @@ "json_migration.heading.aliases": "Aliase:", "json_migration.heading.colors": "Farben:", "json_migration.heading.differ": "Diskrepanz", - "json_migration.heading.extension_list_type": "Erweiterungslistentyp:", - "json_migration.heading.file_extension_list": "Liste der Dateiendungen:", "json_migration.heading.match": "Übereinstimmend", "json_migration.heading.names": "Namen:", "json_migration.heading.parent_tags": "Übergeordnete Tags:", @@ -180,27 +182,27 @@ "library.name": "Bibliothek", "library.refresh.scanning.plural": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Dateien durchsucht, {found_count} neue Dateien gefunden", "library.refresh.scanning.singular": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Datei durchsucht, {found_count} neue Datei gefunden", + "library.refresh.scanning_preparing": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...", + "library.refresh.title": "Verzeichnisse werden aktualisiert", + "library.scan_library.title": "Bibliothek wird scannen", + "library_info.cleanup": "Aufräumen", "library_info.cleanup.backups": "Bibliotheks-Backups:", "library_info.cleanup.dupe_files": "Doppelte Dateien:", "library_info.cleanup.ignored": "Ausgeblendete Einträge:", "library_info.cleanup.legacy_json": "Übriggebliebene Legacybibliotheken:", "library_info.cleanup.unlinked": "Nicht verlinkte Einträge:", - "library_info.cleanup": "Aufräumen", + "library_info.stats": "Statistiken", "library_info.stats.colors": "Tagfarben:", "library_info.stats.entries": "Einträge:", "library_info.stats.fields": "Felder:", "library_info.stats.macros": "Macros:", "library_info.stats.namespaces": "Namespaces:", "library_info.stats.tags": "Tags:", - "library_info.stats": "Statistiken", "library_info.title": "Bibliothek '{library_dir}'", "library_info.version": "Formatsversion der Bibliothek: {version}", - "library_object.name_required": "Name (erforderlich)", "library_object.name": "Name", + "library_object.name_required": "Name (erforderlich)", "library_object.slug": "ID Schlüssel", - "library.refresh.scanning_preparing": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...", - "library.refresh.title": "Verzeichnisse werden aktualisiert", - "library.scan_library.title": "Bibliothek wird scannen", "library_object.slug_required": "ID Schlüssel (erforderlich)", "macros.running.dialog.new_entries": "Führe konfigurierte Makros für {count}/{total} neue Dateieinträge aus...", "macros.running.dialog.title": "Ausführen von Makros bei neuen Einträgen", @@ -266,6 +268,7 @@ "settings.generate_thumbs": "Generiere Thumbnails", "settings.global": "Globale Einstellungen", "settings.hourformat.label": "24-Stunden Format", + "settings.infinite_scroll": "Unendliches Scrollen", "settings.language": "Sprache", "settings.library": "Bibliothekseinstellungen", "settings.open_library_on_start": "Bibliothek zum Start öffnen", @@ -273,7 +276,13 @@ "settings.restart_required": "Bitte TagStudio neustarten, um Änderungen anzuwenden.", "settings.show_filenames_in_grid": "Dateinamen in Raster darstellen", "settings.show_recent_libraries": "Zuletzt verwendete Bibliotheken anzeigen", - "settings.tag_click_action.add_to_search": "Tag zu Suche hinzufügen", + "settings.splash.label": "Start Bildschrim", + "settings.splash.option.classic": "Klassisch (9.0)", + "settings.splash.option.default": "Standard", + "settings.splash.option.goo_gears": "Open Source (9.4)", + "settings.splash.option.ninety_five": "'95 (9.5)", + "settings.splash.option.random": "Zufall", + "settings.tag_click_action.add_to_search": "Tag zur Suche hinzufügen", "settings.tag_click_action.label": "Tag Klick Aktion", "settings.tag_click_action.open_edit": "Tag bearbeiten", "settings.tag_click_action.set_search": "Nach Tag suchen", @@ -281,6 +290,7 @@ "settings.theme.label": "Design:", "settings.theme.light": "Hell", "settings.theme.system": "System", + "settings.thumb_cache_size.label": "Thumbnail Cache Größe", "settings.title": "Einstellungen", "settings.zeropadding.label": "Platzsparendes Datum", "sorting.direction.ascending": "Aufsteigend", @@ -300,7 +310,7 @@ "status.library_search_query": "Durchsuche die Bibliothek...", "status.library_version_expected": "Erwartet:", "status.library_version_found": "Gefunden:", - "status.library_version_mismatch": "BIbliotheksversion stimmt nicht überein!", + "status.library_version_mismatch": "Bibliotheksversion stimmt nicht überein!", "status.results": "Ergebnisse", "status.results.invalid_syntax": "Ungültige Such-Syntax:", "status.results_found": "{count} Ergebnisse gefunden ({time_span})", @@ -317,6 +327,7 @@ "tag.disambiguation.tooltip": "Diesen Tag zur Unterscheidung verwenden", "tag.edit": "Tag bearbeiten", "tag.is_category": "Ist Kategorie", + "tag.is_hidden": "Ist versteckt", "tag.name": "Name", "tag.new": "Neuer Tag", "tag.parent_tags": "Übergeordnete Tags", @@ -340,6 +351,9 @@ "trash.dialog.title.singular": "Datei löschen", "trash.name.generic": "Mülleimer", "trash.name.windows": "Papierkorb", + "version_modal.description": "Eine neue Version von TagStudio ist verfügbar! Du kannst die neueste Version auf GitHub herunterladen.", + "version_modal.status": "Installierte Version: {installed_version}
    Letzte veröffentlichte Version: {latest_release_version}", + "version_modal.title": "TagStudio Aktualisierung verfügbar", "view.size.0": "Mini", "view.size.1": "Klein", "view.size.2": "Mittel", diff --git a/src/tagstudio/resources/translations/el.json b/src/tagstudio/resources/translations/el.json new file mode 100644 index 000000000..02d98b74b --- /dev/null +++ b/src/tagstudio/resources/translations/el.json @@ -0,0 +1,162 @@ +{ + "about.config_path": "Διαδρομή Config", + "about.description": "ο TagStudio είναι μια εφαρμογή οργάνωσης φωτογραφιών και αρχείων με ένα υποκείμενο σύστημα βασισμένο σε ετικέτες που εστιάζει στην παροχή ελευθερίας και ευελιξίας στον χρήστη. Χωρίς ιδιόκτητα προγράμματα ή μορφές, χωρίς πληθώρα αρχείων και χωρίς πλήρη ανατροπή της δομής του συστήματος αρχείων σας.", + "about.documentation": "Τεκμηρίωση", + "about.license": "Άδεια χρήσης", + "about.module.found": "Βρέθηκε", + "about.title": "Πληροφορίες για το TagStudio", + "about.website": "Ιστοσελίδα", + "app.git": "Git Commit", + "app.pre_release": "Pre-Release", + "app.title": "{base_title} Βιβλιοθήκη \"{library_dir} «««", + "color.color_border": "Χρησιμοποιήστε το δευτερεύον χρώμα για τα σύνορα", + "color.confirm_delete": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το χρώμα \"{color_name}\";", + "color.delete": "Διαγράψτε Tag", + "color.import_pack": "Εισαγωγή Πακέτου Χρωμάτων", + "color.name": "Όνομα", + "color.namespace.delete.prompt": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτόν τον χώρο ονόματος χρωμάτων; Αυτό θα διαγράψει όλα τα χρώματα στο χώρο του ονόματος μαζί του!", + "color.namespace.delete.title": "Διαγράψτε Color Namespace", + "color.new": "Νέο χρώμα", + "color.placeholder": "Χρώμα", + "color.primary": "Βασικό Χρώμα", + "color.primary_required": "Βασικό Χρώμα (Υποχρεωτικό)", + "color.secondary": "Δευτερεύον Χρώμα", + "color.title.no_color": "Χωρίς Χρώμα", + "color_manager.title": "Διαχείριση χρωμάτων Tag", + "dependency.missing.title": "{dependency} Δεν υπάρχει", + "drop_import.description": "Τα παρακάτω αρχεία ταιριάζουν μονοπάτια αρχείων που υπάρχουν ήδη στη βιβλιοθήκη", + "drop_import.duplicates_choice.plural": "Τα παρακάτω αρχεία {count} ταιριάζουν μονοπάτια αρχείων που υπάρχουν ήδη στη βιβλιοθήκη.", + "drop_import.duplicates_choice.singular": "Το παρακάτω αρχείο ταιριάζει με ένα μονοπάτι αρχείου που υπάρχει ήδη στη βιβλιοθήκη.", + "drop_import.progress.label.initial": "Εισαγωγή νέων αρχείων...", + "drop_import.progress.label.plural": "Εισαγωγή νέων αρχείων...\n{count} Αρχεία Εισήχθησαν.{suffix}", + "drop_import.progress.label.singular": "Εισαγωγή νέων αρχείων...\n1 Αρχείο εισήχθη.{suffix}", + "drop_import.progress.window_title": "Εισαγωγή αρχείων", + "drop_import.title": "Αντικρουόμενα αρχεία(s)", + "edit.color_manager": "Διαχείριση χρωμάτων Tag", + "edit.copy_fields": "Αντιγραφή πεδίων", + "edit.paste_fields": "Επικόλληση πεδίων", + "edit.tag_manager": "Διαχείριση Tags", + "entries.duplicate.merge": "Συγχώνευση διπλότυπων εγγραφών", + "entries.duplicate.merge.label": "Συγχώνευση διπλότυπων εγγραφών...", + "entries.duplicate.refresh": "Ανανέωση διπλότυπων εγγραφών", + "entries.duplicates.description": "Ως διπλότυπες εγγραφές ορίζονται οι πολλαπλές εγγραφές που υποδεικνύουν το ίδιο αρχείο στον δίσκο. Η συγχώνευση αυτών θα συνδυάσει τις ετικέτες και τα μεταδεδομένα από όλα τα διπλότυπα σε μια ενιαία, ενοποιημένη εγγραφή. Αυτές δεν πρέπει να συγχέονται με τα \"διπλότυπα αρχεία\", τα οποία είναι αντίγραφα των ίδιων των αρχείων σας εκτός του TagStudio.", + "entries.generic.refresh_alt": "&Ανανέωση", + "entries.generic.remove.removing": "Αφαίρεση εγγραφών", + "entries.generic.remove.removing_count": "Αφαίρεση {count} εγγραφών...", + "entries.ignored.description": "Οι εγγραφές αρχείων θεωρούνται \"αγνοημένες\" εάν προστέθηκαν στη βιβλιοθήκη πριν ενημερωθούν οι κανόνες εξαίρεσης του χρήστη (μέσω του αρχείου '.ts_ignore') για να τα αποκλείσουν. Τα αγνοημένα αρχεία διατηρούνται στη βιβλιοθήκη από προεπιλογή, προκειμένου να αποφευχθεί η τυχαία απώλεια δεδομένων κατά την ενημέρωση των κανόνων εξαίρεσης.", + "entries.ignored.ignored_count": "Αγνοημένες εγγραφές: {count}", + "entries.ignored.remove": "Αφαίρεση αγνοημένων εγγραφών", + "entries.ignored.remove_alt": "Α&φαίρεση αγνοημένων εγγραφών", + "entries.ignored.scanning": "Σάρωση βιβλιοθήκης για αγνοημένες εγγραφές...", + "entries.ignored.title": "Διόρθωση αγνοημένων εγγραφών", + "entries.mirror": "&Αντικατοπτρισμός", + "entries.mirror.confirmation": "Είστε βέβαιοι ότι θέλετε να αντικατοπτρίσετε τις ακόλουθες {count} εγγραφές;", + "entries.mirror.label": "Αντικατοπτρισμός {idx}/{total} εγγραφών...", + "entries.mirror.title": "Αντικατοπτρισμός εγγραφών", + "entries.mirror.window_title": "Αντικατοπτρισμός εγγραφών", + "entries.remove.plural.confirm": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτές τις {count} εγγραφές από τη βιβλιοθήκη σας; Δεν θα διαγραφεί κανένα αρχείο από τον δίσκο.", + "entries.remove.singular.confirm": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτή την εγγραφή από τη βιβλιοθήκη σας; Δεν θα διαγραφεί κανένα αρχείο από τον δίσκο.", + "entries.running.dialog.new_entries": "Προσθήκη {total} νέων εγγραφών αρχείων...", + "entries.running.dialog.title": "Προσθήκη νέων εγγραφών αρχείων", + "entries.tags": "Tags", + "entries.unlinked.description": "Κάθε εγγραφή της βιβλιοθήκης είναι συνδεδεμένη με ένα αρχείο σε έναν από τους καταλόγους σας. Εάν ένα αρχείο που είναι συνδεδεμένο με μια εγγραφή μετακινηθεί ή διαγραφεί εκτός του TagStudio, τότε θεωρείται αποσυνδεδεμένο.

    Οι αποσυνδεδεμένες εγγραφές μπορούν να επανασυνδεθούν αυτόματα μέσω αναζήτησης στους καταλόγους σας ή να διαγραφούν, εάν το επιθυμείτε.", + "entries.unlinked.relink.attempting": "Προσπάθεια επανασύνδεσης {index}/{unlinked_count} εγγραφών, {fixed_count} επανασυνδέθηκαν επιτυχώς", + "entries.unlinked.relink.manual": "&Χειροκίνητη επανασύνδεση", + "entries.unlinked.relink.title": "Επανασύνδεση εγγραφών", + "entries.unlinked.remove": "Αφαίρεση αποσυνδεδεμένων εγγραφών", + "entries.unlinked.remove_alt": "Α&φαίρεση αποσυνδεδεμένων εγγραφών", + "entries.unlinked.scanning": "Σάρωση βιβλιοθήκης για αποσυνδεδεμένες εγγραφές...", + "entries.unlinked.search_and_relink": "&Αναζήτηση && Επανασύνδεση", + "entries.unlinked.title": "Διόρθωση αποσυνδεδεμένων εγγραφών", + "entries.unlinked.unlinked_count": "Αποσυνδεδεμένες εγγραφές: {count}", + "ffmpeg.missing.description": "Δεν βρέθηκαν τα FFmpeg ή/και FFprobe. Το FFmpeg είναι απαραίτητο για την αναπαραγωγή πολυμέσων και τη δημιουργία μικρογραφιών.", + "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.copy": "Αντιγραφή πεδίου", + "field.edit": "Επεξεργασία πεδίου", + "field.paste": "Επικόλληση πεδίου", + "file.date_added": "Ημερομηνία προσθήκης", + "file.date_created": "Ημερομηνία δημιουργίας", + "file.date_modified": "Ημερομηνία τροποποίησης", + "file.dimensions": "Διαστάσεις", + "file.duplicates.description": "Το TagStudio υποστηρίζει την εισαγωγή αποτελεσμάτων από το DupeGuru για τη διαχείριση διπλότυπων αρχείων.", + "file.duplicates.dupeguru.advice": "Μετά τον κατοπτρισμό, μπορείτε ελεύθερα να χρησιμοποιήσετε το DupeGuru για να διαγράψετε τα ανεπιθύμητα αρχεία. Στη συνέχεια, χρησιμοποιήστε τη λειτουργία «Διόρθωση αποσυνδεδεμένων εγγραφών» στο μενού «Εργαλεία» του TagStudio, προκειμένου να διαγράψετε τις αποσυνδεδεμένες εγγραφές.", + "file.duplicates.dupeguru.file_extension": "Αρχεία DupeGuru (*.dupeguru)", + "file.duplicates.dupeguru.load_file": "&Φόρτωση αρχείου DupeGuru", + "file.duplicates.dupeguru.no_file": "Δεν επιλέχθηκε αρχείο DupeGuru", + "file.duplicates.dupeguru.open_file": "Άνοιγμα αρχείου αποτελεσμάτων DupeGuru", + "file.duplicates.fix": "Διόρθωση διπλότυπων αρχείων", + "file.duplicates.matches": "Αντιστοιχίες διπλότυπων αρχείων: {count}", + "file.duplicates.matches_uninitialized": "Αντιστοιχίες διπλότυπων αρχείων: Μ/Δ", + "file.duplicates.mirror.description": "Κατοπτρισμός των δεδομένων της εγγραφής σε κάθε σύνολο αντιστοιχισμένων διπλοτύπων, συνδυάζοντας όλα τα δεδομένα χωρίς την αφαίρεση ή την επανάληψη πεδίων. Αυτή η λειτουργία δεν θα διαγράψει αρχεία ή δεδομένα.", + "file.duplicates.mirror_entries": "&Κατοπτρισμός εγγραφών", + "file.duration": "Διάρκεια", + "file.not_found": "Το αρχείο δεν βρέθηκε", + "file.open_file": "Άνοιγμα αρχείου", + "file.open_file_with": "Άνοιγμα αρχείου με", + "file.open_location.generic": "Εμφάνιση αρχείου στην Εξερεύνηση αρχείων", + "file.open_location.mac": "Εμφάνιση στο Finder", + "file.open_location.windows": "Εμφάνιση στην Εξερεύνηση αρχείων", + "file.path": "Διαδρομή αρχείου", + "folders_to_tags.close_all": "Κλείσιμο όλων", + "folders_to_tags.converting": "Μετατροπή φακέλων σε Tags", + "folders_to_tags.description": "Δημιουργεί ετικέτες με βάση τη δομή των φακέλων σας και τις εφαρμόζει στις εγγραφές σας. \nΗ παρακάτω δομή εμφανίζει όλες τις ετικέτες που πρόκειται να δημιουργηθούν, καθώς και σε ποιες εγγραφές θα εφαρμοστούν.", + "folders_to_tags.open_all": "Άνοιγμα όλων", + "folders_to_tags.title": "Δημιουργία ετικετών από φακέλους", + "generic.add": "Προσθήκη", + "generic.apply": "Εφαρμογή", + "generic.apply_alt": "&Εφαρμογή", + "generic.cancel": "Ακύρωση", + "generic.cancel_alt": "&Ακύρωση", + "generic.close": "Κλείσιμο", + "generic.continue": "Συνέχεια", + "generic.copy": "Αντιγραφή", + "generic.cut": "Αποκοπή", + "generic.delete": "Διαγραφή", + "generic.delete_alt": "&Διαγραφή", + "generic.done": "Τέλος", + "generic.done_alt": "&Τέλος", + "generic.edit": "Επεξεργασία", + "generic.edit_alt": "&Επεξεργασία", + "generic.filename": "Όνομα αρχείου", + "generic.missing": "Λείπει", + "generic.navigation.back": "Πίσω", + "generic.navigation.next": "Επόμενο", + "generic.no": "Όχι", + "generic.none": "Κανένα", + "generic.overwrite": "Αντικατάσταση", + "generic.overwrite_alt": "&Αντικατάσταση", + "generic.paste": "Επικόλληση", + "generic.recent_libraries": "Πρόσφατες βιβλιοθήκες", + "generic.remove": "Αφαίρεση", + "generic.remove_alt": "&Αφαίρεση", + "generic.rename": "Μετονομασία", + "generic.rename_alt": "&Μετονομασία", + "generic.reset": "Επαναφορά", + "generic.save": "Αποθήκευση", + "generic.skip": "Παράλειψη", + "generic.skip_alt": "&Παράλειψη", + "generic.yes": "Ναι", + "home.search": "Αναζήτηση", + "home.search_entries": "Αναζήτηση καταχωρίσεων", + "home.search_library": "Αναζήτηση στη βιβλιοθήκη", + "home.search_tags": "Αναζήτηση ετικετών", + "home.show_hidden_entries": "Εμφάνιση κρυφών καταχωρίσεων", + "home.thumbnail_size": "Μέγεθος μικρογραφιών", + "home.thumbnail_size.extra_large": "Πολύ μεγάλες μικρογραφίες", + "home.thumbnail_size.large": "Μεγάλες μικρογραφίες", + "home.thumbnail_size.medium": "Μεσαίες μικρογραφίες", + "home.thumbnail_size.mini": "Μίνι μικρογραφίες", + "home.thumbnail_size.small": "Μικρές μικρογραφίες", + "ignore.open_file": "Εμφάνιση του αρχείου \"{ts_ignore}\" στον δίσκο", + "json_migration.checking_for_parity": "Έλεγχος ισοτιμίας...", + "json_migration.creating_database_tables": "Δημιουργία πινάκων βάσης δεδομένων SQL...", + "json_migration.description": "
    Εκκινήστε και προεπισκοπήστε τα αποτελέσματα της διαδικασίας μεταφοράς της βιβλιοθήκης. Η μετατραπείσα βιβλιοθήκη δεν θα χρησιμοποιηθεί εκτός αν κάνετε κλικ στο \"Ολοκλήρωση μεταφοράς\".

    Τα δεδομένα της βιβλιοθήκης θα πρέπει είτε να έχουν τιμές που συμπίπτουν είτε να φέρουν την ετικέτα \"Αντιστοιχίστηκε\". Οι τιμές που δεν συμπίπτουν θα εμφανίζονται με κόκκινο χρώμα και θα φέρουν το σύμβολο \"(!)\" δίπλα τους.
    Αυτή η διαδικασία μπορεί να διαρκέσει αρκετά λεπτά για μεγάλες βιβλιοθήκες.
    ", + "json_migration.discrepancies_found": "Βρέθηκαν ασυμφωνίες στη βιβλιοθήκη", + "json_migration.discrepancies_found.description": "Βρέθηκαν ασυμφωνίες μεταξύ της αρχικής και της μετατραπείσας μορφής της βιβλιοθήκης. Παρακαλούμε ελέγξτε και επιλέξτε αν θα συνεχίσετε με τη μεταφορά ή αν θα την ακυρώσετε.", + "json_migration.finish_migration": "Ολοκλήρωση μεταφοράς", + "json_migration.heading.aliases": "Ψευδώνυμα:", + "json_migration.heading.colors": "Χρώματα:", + "json_migration.heading.differ": "Ασυμφωνία", + "json_migration.heading.match": "Αντιστοιχίστηκε", + "json_migration.heading.names": "Όνομα:" +} diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 1159bd4f0..099f443a5 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -71,6 +71,9 @@ "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", "ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field_type.datetime": "Datetime", + "field_type.text": "Text", + "field_type.unknown": "Unknown Type", "field.copy": "Copy Field", "field.edit": "Edit Field", "field.paste": "Paste Field", @@ -157,8 +160,7 @@ "json_migration.heading.aliases": "Aliases:", "json_migration.heading.colors": "Colors:", "json_migration.heading.differ": "Discrepancy", - "json_migration.heading.extension_list_type": "Extension List Type:", - "json_migration.heading.file_extension_list": "File Extension List:", + "json_migration.heading.extensions": "Extensions:", "json_migration.heading.match": "Matched", "json_migration.heading.names": "Names:", "json_migration.heading.parent_tags": "Parent Tags:", @@ -276,6 +278,7 @@ "settings.open_library_on_start": "Open Library on Start", "settings.page_size": "Page Size", "settings.restart_required": "Please restart TagStudio for changes to take effect.", + "settings.scan_files_on_open": "Automatically Load New Files", "settings.show_filenames_in_grid": "Show Filenames in Grid", "settings.show_recent_libraries": "Show Recent Libraries", "settings.splash.label": "Splash Screen", @@ -353,6 +356,9 @@ "trash.dialog.title.singular": "Delete File", "trash.name.generic": "Trash", "trash.name.windows": "Recycle Bin", + "version_modal.title": "TagStudio Update Available", + "version_modal.description": "A new version of TagStudio is available! You can download the latest release from GitHub.", + "version_modal.status": "Installed Version: {installed_version}
    Latest Release Version: {latest_release_version}", "view.size.0": "Mini", "view.size.1": "Small", "view.size.2": "Medium", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index d162b9500..0ed8967a6 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -1,5 +1,5 @@ { - "about.config_path": "Ruta de configuración", + "about.config_path": "Ruta de Configuración", "about.description": "TagStudio es una aplicación para organizar fotografías y archivos que utiliza un sistema de etiquetas subyacentes centrado en dar libertad y flexibilidad al usuario. Sin programas ni formatos propios, ni un mar de archivos y sin trastornar completamente la estructura de tu sistema de archivos.", "about.documentation": "Documentación", "about.license": "Licencia", @@ -54,7 +54,7 @@ "entries.mirror.label": "Reflejando {idx}/{total} Entradas...", "entries.mirror.title": "Reflejando entradas", "entries.mirror.window_title": "Reflejar entradas", - "entries.remove.plural.confirm": "¿Está seguro de que desea eliminar las siguientes {count} entradas?", + "entries.remove.plural.confirm": "¿Está seguro de que desea eliminar estas {count} entradas de su librería? No se eliminará ningún archivo del disco.", "entries.remove.singular.confirm": "¿Está seguro que quiere eliminar ésta entrada de su librería? Ningún archivo en el disco será eliminado.", "entries.running.dialog.new_entries": "Añadiendo {total} nuevas entradas de archivos...", "entries.running.dialog.title": "Añadiendo las nuevas entradas de archivos", @@ -140,6 +140,7 @@ "home.search_entries": "Buscar entradas", "home.search_library": "Buscar el biblioteca", "home.search_tags": "Buscar etiquetas", + "home.show_hidden_entries": "Mostrar entradas ocultas", "home.thumbnail_size": "Tamaño de la vista previa", "home.thumbnail_size.extra_large": "Imágenes extra grandes", "home.thumbnail_size.large": "Imágenes grandes", @@ -156,8 +157,6 @@ "json_migration.heading.aliases": "Alias:", "json_migration.heading.colors": "Colores:", "json_migration.heading.differ": "Discrepancia", - "json_migration.heading.extension_list_type": "Tipo de lista de extensión:", - "json_migration.heading.file_extension_list": "Lista de extensiones de archivos:", "json_migration.heading.match": "Igualado", "json_migration.heading.names": "Nombres:", "json_migration.heading.parent_tags": "Etiquetas principales:", @@ -206,8 +205,8 @@ "macros.running.dialog.title": "Ejecución de macros en entradas nuevas", "media_player.autoplay": "Reproducción automática", "media_player.loop": "Bucle", - "menu.delete_selected_files_ambiguous": "Mover archivo(s) a la {trash_term}", - "menu.delete_selected_files_plural": "Mover archivos a la {trash_term}", + "menu.delete_selected_files_ambiguous": "Mover Archivo(s) a la {trash_term}", + "menu.delete_selected_files_plural": "Mover Archivos a la {trash_term}", "menu.delete_selected_files_singular": "Mover archivo a la {trash_term}", "menu.edit": "Editar", "menu.edit.ignore_files": "Ignorar archivos y carpetas", @@ -217,13 +216,13 @@ "menu.file.clear_recent_libraries": "Borrar recientes", "menu.file.close_library": "&Cerrar biblioteca", "menu.file.missing_library.message": "La ubicación de la biblioteca \"{library}\" no se ha podido encontrar.", - "menu.file.missing_library.title": "Biblioteca desaparecida", + "menu.file.missing_library.title": "Librería No Encontrada", "menu.file.new_library": "Nueva biblioteca", "menu.file.open_backups_folder": "Abrir Carpeta de Respaldos", "menu.file.open_create_library": "&Abrir/Crear biblioteca", "menu.file.open_library": "Abrir biblioteca", "menu.file.open_recent_library": "Abrir reciente", - "menu.file.refresh_directories": "&Actualizar directorios", + "menu.file.refresh_directories": "Actualizar directorios", "menu.file.save_backup": "&Guardar copia de seguridad de la biblioteca", "menu.file.save_library": "Guardar biblioteca", "menu.help": "&Ayuda", @@ -266,6 +265,7 @@ "settings.generate_thumbs": "Generación de Miniaturas", "settings.global": "Ajustes globales", "settings.hourformat.label": "Formato 24-horas", + "settings.infinite_scroll": "Desplazamiento infinito", "settings.language": "Idioma", "settings.library": "Ajustes de la biblioteca", "settings.open_library_on_start": "Abrir biblioteca al iniciar", @@ -287,7 +287,7 @@ "settings.theme.label": "Tema:", "settings.theme.light": "Claro", "settings.theme.system": "Sistema", - "settings.thumb_cache_size.label": "Tamaño cache de miniaturas", + "settings.thumb_cache_size.label": "Tamaño de la caché de miniaturas", "settings.title": "Ajustes", "settings.zeropadding.label": "Rellenar ceros en fechas", "sorting.direction.ascending": "Ascendiente", @@ -324,6 +324,7 @@ "tag.disambiguation.tooltip": "Utiliza esta etiqueta para desambiguar", "tag.edit": "Editar etiqueta", "tag.is_category": "Es categoría", + "tag.is_hidden": "Está oculto", "tag.name": "Nombre", "tag.new": "Nueva etiqueta", "tag.parent_tags": "Etiquetas principales", @@ -347,6 +348,9 @@ "trash.dialog.title.singular": "Eliminar archivo", "trash.name.generic": "Basura", "trash.name.windows": "Papelera de reciclaje", + "version_modal.description": "¡Ya está disponible una nueva versión de TagStudio! Puedes descargar la última versión desde Github.", + "version_modal.status": "Versión Instalada: {installed_version}
    Última Versión Publicada: {latest_release_version}", + "version_modal.title": "Actualización de TagStudio disponible", "view.size.0": "Mini", "view.size.1": "Pequeño", "view.size.2": "Medio", diff --git a/src/tagstudio/resources/translations/fi.json b/src/tagstudio/resources/translations/fi.json new file mode 100644 index 000000000..b82181622 --- /dev/null +++ b/src/tagstudio/resources/translations/fi.json @@ -0,0 +1,321 @@ +{ + "about.config_path": "Konfiguraatio polku", + "about.description": "TagStudio on valokuvien ja tiedostojen järjestämiseen tarkoitettu sovellus, jonka taustalla oleva tagipohjainen järjestelmä keskittyy antamaan käyttäjälle vapautta ja joustavuutta. Ei suljettuja ohjelmia tai formaatteja, ei valtavaa sivutiedostojen merta eikä tiedostojärjestelmän täydellistä mullistamista.", + "about.documentation": "Dokumentaatio", + "about.license": "Lisenssi", + "about.module.found": "Löytyi", + "about.title": "Tietoa TagStudiosta", + "about.website": "Verkkosivu", + "app.git": "Git Commit", + "app.pre_release": "Pre-Release", + "app.title": "{base_title} - Kirjasto '{library_dir}'", + "color.color_border": "Käytä toissijaista väriä reunalle", + "color.confirm_delete": "Oletko varma, että haluat poistaa värin \"{color_name}\"?", + "color.delete": "Poista tunniste", + "color.import_pack": "Tuo väri paketteja", + "color.name": "Nimi", + "color.namespace.delete.prompt": "Oletko varma, että haluat poistaa tämän värin? Tämä poistaa kaikki värit sen mukana!", + "color.namespace.delete.title": "Poista värin nimi", + "color.new": "Uusi väri", + "color.placeholder": "Väri", + "color.primary": "Pääväri", + "color.primary_required": "Pääväri (Vaadittu)", + "color.secondary": "Toissijainen väri", + "color.title.no_color": "Ei väriä", + "color_manager.title": "Hallitse tunnisteiden värejä", + "dependency.missing.title": "{dependency} Ei löytynyt", + "drop_import.description": "Seuraavat tiedostot vastaavat tiedostoja, jotka ovat jo olemassa kirjastossa", + "drop_import.duplicates_choice.plural": "Seuraavat {count} tiedostoa vastaavat tiedostoja, jotka ovat jo olemassa kirjastossa.", + "drop_import.duplicates_choice.singular": "Seuraava tiedosto vastaa tiedostopolkua, joka on jo olemassa kirjastossa.", + "drop_import.progress.label.initial": "Tuo uusi tiedosto...", + "drop_import.progress.label.plural": "Tuodaan uusia tiedostoja...\n{count} Tiedostoa tuotu.{suffix}", + "drop_import.progress.label.singular": "Uusien tiedostojen tuonti...\n1 tiedosto tuotu {suffix}", + "drop_import.progress.window_title": "Tuo tiedostoja", + "drop_import.title": "Ristiriitaisia tiedosto(ja)", + "edit.color_manager": "Hallitse tunnisteiden värejä", + "edit.copy_fields": "Kopioi kenttiä", + "edit.paste_fields": "Liitä kenttiä", + "edit.tag_manager": "Hallitse Tagejä", + "entries.duplicate.merge": "Yhdistä kaksoiskappaleita", + "entries.duplicate.merge.label": "Yhdistetään kaksoiskappaleita...", + "entries.duplicate.refresh": "Virkistä kaksoiskappaleet", + "entries.duplicates.description": "Kaksoiskappaleilla tarkoitetaan useita merkintöjä, jotka osoittavat samaan tiedostoon levyllä. Näiden yhdistäminen yhdistää kaikkien kaksoiskappaleiden tunnisteet ja metatiedot yhdeksi yhdistetyksi merkinnäksi. Näitä ei pidä sekoittaa \"kopiotiedostoihin\", jotka ovat tiedostojesi kopioita TagStudion ulkopuolella.", + "entries.generic.refresh_alt": "&Refresh", + "entries.generic.remove.removing": "Poistetaan merkintöjä", + "entries.generic.remove.removing_count": "Poistetaan {count} merkintää...", + "entries.ignored.description": "Tiedostomerkintöjä pidetään \"ohitetuina\", jos ne lisättiin kirjastoon ennen kuin käyttäjän ohitussäännöt ('.ts_ignore'-tiedoston kautta) päivitettiin niiden poissulkemiseksi. Ohitetut tiedostot säilytetään kirjastossa oletusarvoisesti vahingossa tapahtuvan tietojen menetyksen estämiseksi ohitussääntöjä päivitettäessä.", + "entries.ignored.ignored_count": "Ohitetut merkinnät {count}", + "entries.ignored.remove": "Poista ohitetut merkinnät", + "entries.ignored.remove_alt": "Remo&ve Ignored Entries", + "entries.ignored.scanning": "Kirjaston skannaus ohitettujen merkintöjen varalta...", + "entries.ignored.title": "Korjaa ohitetut merkinnät", + "entries.mirror": "&Mirror", + "entries.mirror.confirmation": "Oletko varma, että haluat peilata seuraavat {count} merkintää?", + "entries.mirror.label": "Peilataan {idx}/{total} merkintää...", + "entries.mirror.title": "Peilataan merkintöjä", + "entries.mirror.window_title": "Peilaa merkinnät", + "entries.remove.plural.confirm": "Haluatko varmasti poistaa nämä {count} merkintää kirjastostasi? Levyllä olevia tiedostoja ei poisteta.", + "entries.remove.singular.confirm": "Haluatko varmasti poistaa tämän merkinnän kirjastostasi? Levyllä olevia tiedostoja ei poisteta.", + "entries.running.dialog.new_entries": "Lisätään {total} uutta tiedosto merkintää...", + "entries.running.dialog.title": "Lisätään uudet tiedosto merkinnät", + "entries.tags": "Tunnisteet", + "entries.unlinked.description": "Jokainen kirjastomerkintä on linkitetty tiedostoon jossakin hakemistoistasi. Jos merkintään linkitetty tiedosto siirretään tai poistetaan TagStudion ulkopuolelle, sitä pidetään linkittämättömänä.

    Linkittämättömät merkinnät voidaan linkittää automaattisesti uudelleen hakemistojen haun avulla tai poistaa haluttaessa.", + "entries.unlinked.relink.attempting": "Yritetään linkittää uudelleen {index}/{unlinked_count} merkintää, {fixed_count} uudelleenlinkitys onnistui", + "entries.unlinked.relink.manual": "&Manual Relink", + "entries.unlinked.relink.title": "Uudelleen yhdistetään merkintöjä", + "entries.unlinked.remove": "Poista linkittämättömät merkinnät", + "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", + "entries.unlinked.scanning": "Skannataan kirjastosta linkittämättömiä merkintöjä...", + "entries.unlinked.search_and_relink": "&Search && Relink", + "entries.unlinked.title": "Korjaa linkittämättömät merkinnät", + "entries.unlinked.unlinked_count": "Linkittämättömät merkinnät: {count}", + "ffmpeg.missing.description": "FFmpegiä ja/tai FFprobea ei löytynyt. FFmpeg vaaditaan multimedian toistoon ja pikkukuvien näyttämiseen.", + "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.copy": "Kopioi kenttä", + "field.edit": "Muokkaa kenttää", + "field.paste": "Liitä kenttä", + "file.date_added": "Päiväys lisätty", + "file.date_created": "Päiväys luotu", + "file.date_modified": "Päiväys muokattu", + "file.dimensions": "Ulottuvuus", + "file.duplicates.description": "TagStudio tukee DupeGuru-tulosten tuontia kaksoiskappaleiden hallintaa varten.", + "file.duplicates.dupeguru.advice": "Peilauksen jälkeen voit käyttää DupeGurua ei-toivottujen tiedostojen poistamiseen. Tämän jälkeen voit poistaa linkittämättömät merkinnät TagStudion Työkalut-valikon \"Korjaa linkittämättömät merkinnät\" -toiminnolla.", + "file.duplicates.dupeguru.file_extension": "DupeGuru tiedostot (*.dupeguru)", + "file.duplicates.dupeguru.load_file": "&Load DupeGuru File", + "file.duplicates.dupeguru.no_file": "Ei valittua DupeGuru-tiedostoa", + "file.duplicates.dupeguru.open_file": "Avaa DupeGuru-tulostiedosto", + "file.duplicates.fix": "Korjaa kaksoiskappale tiedostot", + "file.duplicates.matches": "Kaksoiskappale tiedostoa vastaa: {count}", + "file.duplicates.matches_uninitialized": "Kaksoiskappaletta vastaava tiedosto: Ei saatavilla", + "file.duplicates.mirror.description": "Peilaa syöttötiedot kaikkiin kaksoiskappaleiden osumajoukkoihin yhdistämällä kaikki tiedot poistamatta tai kopioimatta kenttiä. Tämä toiminto ei poista tiedostoja tai tietoja.", + "file.duplicates.mirror_entries": "&Mirror Entries", + "file.duration": "Pituus", + "file.not_found": "Tiedostoa ei löytynyt", + "file.open_file": "Avaa tiedosto", + "file.open_file_with": "Avaa tiedosto", + "file.open_location.generic": "Avaa tiedosto resurssienhallinnassa", + "file.open_location.mac": "Näytä Finderissa", + "file.open_location.windows": "Näytä resurssienhallinnassa", + "file.path": "Tiedoston polku", + "folders_to_tags.close_all": "Sulje kaikki", + "folders_to_tags.converting": "Muutetaan kansiot tageiksi", + "folders_to_tags.description": "Luo tunnisteita kansiorakenteesi perusteella ja lisää ne merkintöihisi.\nAlla oleva rakenne näyttää kaikki luotavat tunnisteet ja mihin merkintöihin niitä lisätään.", + "folders_to_tags.open_all": "Avaa kaikki", + "folders_to_tags.title": "Luo tunnisteet kansioista", + "generic.add": "Lisää", + "generic.apply": "Käytä", + "generic.apply_alt": "&Apply", + "generic.cancel": "Peruuta", + "generic.cancel_alt": "&Cancel", + "generic.close": "Sulje", + "generic.continue": "Jatka", + "generic.copy": "Kopioi", + "generic.cut": "Leikkaa", + "generic.delete": "Poista", + "generic.delete_alt": "&Delete", + "generic.done": "Valmis", + "generic.done_alt": "&Done", + "generic.edit": "Muokkaa", + "generic.edit_alt": "&Edit", + "generic.filename": "Tiedoston nimi", + "generic.missing": "Puuttuu", + "generic.navigation.back": "Takaisin", + "generic.navigation.next": "Seuraava", + "generic.no": "Ei", + "generic.none": "Ei mitään", + "generic.overwrite": "Ylikirjoita", + "generic.overwrite_alt": "&Overwrite", + "generic.paste": "Liitä", + "generic.recent_libraries": "Viimeaikaiset kirjastot", + "generic.remove": "Poista", + "generic.remove_alt": "&Remove", + "generic.rename": "Uudelleen nimeä", + "generic.rename_alt": "&Rename", + "generic.reset": "Aloita alusta", + "generic.save": "Tallenna", + "generic.skip": "Ohita", + "generic.skip_alt": "&Skip", + "generic.yes": "Kyllä", + "home.search": "Etsi", + "home.search_entries": "Hae merkintöjä", + "home.search_library": "Hae kirjasto", + "home.search_tags": "Hae tageja", + "home.show_hidden_entries": "Näytä piiloitetut merkinnät", + "home.thumbnail_size": "Pienoiskuvan koko", + "home.thumbnail_size.extra_large": "Extra isot pienoiskuvakkeet", + "home.thumbnail_size.large": "Isot pienoiskuvat", + "home.thumbnail_size.medium": "Keskikokoiset pienoiskuvat", + "home.thumbnail_size.mini": "Mini pienoiskuvat", + "home.thumbnail_size.small": "Pienet pienoiskuvat", + "ignore.open_file": "Näytä tiedosto \"{ts_ignore}\" levyllä", + "json_migration.checking_for_parity": "Pariteettia tarkistetaan...", + "json_migration.creating_database_tables": "Luodaa SQL tietokannan pöytiä...", + "json_migration.description": "
    Aloita kirjaston siirtoprosessi ja esikatsele sen tuloksia. Muunnettua kirjastoa ei käytetä, ellet napsauta \"Lopeta siirto\".

    Kirjastotiedoissa tulee olla joko vastaavat arvot tai niissä tulee olla merkintä \"Vastaava\". Arvot, jotka eivät vastaa toisiaan, näkyvät punaisina ja niiden vieressä on symboli \"(!)\".
    Tämä prosessi voi kestää useita minuutteja suuremmissa kirjastoissa.
    ", + "json_migration.discrepancies_found": "Kirjastossa havaitut ristiriidat", + "json_migration.discrepancies_found.description": "Alkuperäisen ja muunnetun kirjastomuodon välillä havaittiin eroja. Tarkista ja päätä, haluatko jatkaa siirtoa vai peruuttaa sen.", + "json_migration.finish_migration": "Lopeta muutto", + "json_migration.heading.aliases": "Aliaksia:", + "json_migration.heading.colors": "Värit:", + "json_migration.heading.differ": "Poikkeavuus", + "json_migration.heading.match": "Yhdistetty", + "json_migration.heading.names": "Nimet:", + "json_migration.heading.parent_tags": "Päätunnukset:", + "json_migration.heading.paths": "Polut:", + "json_migration.heading.shorthands": "Lyhenne:", + "json_migration.info.description": "TagStudio-versioilla 9.4 ja vanhemmilla luodut kirjaston tallennustiedostot on siirrettävä uuteen v9.5+-muotoon.

    Tiedot:

    • Olemassa olevaa kirjaston tallennustiedostoasi EI poisteta
    • Henkilökohtaisia tiedostojasi EI poisteta, siirretä tai muokata
    • Uutta v9.5+ -tallennusmuotoa ei voi avata TagStudion aiemmissa versioissa

    Mitä on muutettu:

    • \"Tunnistekentät\" on korvattu \"Tunnisteluokat\"-toiminnolla. Sen sijaan, että tunnisteet lisättäisiin ensin kenttiin, tunnisteet lisätään nyt suoraan tiedostomerkintöihin. Ne järjestetään sitten automaattisesti luokkiin päätunnisteiden perusteella, jotka on merkitty uudella \"Onko luokka\" -ominaisuudella tunnisteiden muokkausvalikossa. Mikä tahansa tunniste voidaan merkitä luokaksi, ja alitunnisteet lajittelevat itsensä luokiksi merkittyjen päätunnisteiden alle. \"Suosikki\"- ja \"Arkistoitu\"-tunnisteet perivät nyt uuden \"Metatunnisteet\"-tunnisteen, joka on oletusarvoisesti merkitty kategoriaksi.
    • Tunnisteiden värejä on muokattu ja laajennettu. Joitakin värejä on nimetty uudelleen tai yhdistetty, mutta kaikki tunnisteiden värit muuntuvat edelleen täsmällisiksi tai lähes vastaaviksi versiossa 9.5.
      ", + "json_migration.migrating_files_entries": "Siirretään {entries:,d} tiedostomerkintää...", + "json_migration.migration_complete": "Muutto valmis!", + "json_migration.migration_complete_with_discrepancies": "Siirto valmis, ristiriitaisuuksia löytyi", + "json_migration.start_and_preview": "Aloita ja esikatsele", + "json_migration.title": "Tallennusmuodon siirto: \"{path}\"", + "json_migration.title.new_lib": "

      v9.5+ Kirjasto

      ", + "json_migration.title.old_lib": "

      v9.4 Kirjasto

      ", + "landing.open_create_library": "Avaa/Luo kirjasto {shortcut}", + "library.field.add": "Lisää kenttä", + "library.field.confirm_remove": "Haluatko varmasti poistaa tämän \"{name}\"-kentän?", + "library.field.mixed_data": "Sekalaista dataa", + "library.field.remove": "Poistettu kenttä", + "library.missing": "Kirjaston sijainti puuttuu", + "library.name": "Kirjasto", + "library.refresh.title": "Virkistetty hakemistot", + "library.scan_library.title": "Skannataan kirjastoa", + "library_info.cleanup": "Puhdistus", + "library_info.cleanup.backups": "Kirjasto varmuuskopiot:", + "library_info.cleanup.dupe_files": "Kaksoiskappaleet:", + "library_info.cleanup.ignored": "Ohitetut merkinnät:", + "library_info.cleanup.legacy_json": "Jäljelle jäänyt Legacy -kirjasto:", + "library_info.cleanup.unlinked": "Yhdistämättömät merkinnät:", + "library_info.stats": "Tilastot", + "library_info.stats.colors": "Tunniste värit:", + "library_info.stats.entries": "Merkinnät:", + "library_info.stats.fields": "Kentät:", + "library_info.stats.macros": "Makrot:", + "library_info.stats.namespaces": "Nimiavaruudet:", + "library_info.stats.tags": "Tunnisteet:", + "library_info.title": "Kirjasto '{library_dir}'", + "library_info.version": "Kirjas formaatti versio: {version}", + "library_object.name": "Nimi", + "library_object.name_required": "Nimi (Vaaditaan)", + "library_object.slug": "ID Slug", + "library_object.slug_required": "ID Slug (Vaadittu)", + "media_player.autoplay": "Automaattinen toisto", + "media_player.loop": "Silmukka", + "menu.edit": "Muokkaa", + "menu.edit.manage_tags": "Hallitse tunnisteita", + "menu.edit.new_tag": "Uusi &Tag", + "menu.file": "&File", + "menu.file.clear_recent_libraries": "Puhdista viimeaikaset", + "menu.file.missing_library.title": "Puuttuva kirjasto", + "menu.file.new_library": "Uusi kirjasto", + "menu.file.open_backups_folder": "Avaa varmuuskopio kansio", + "menu.file.open_library": "Avaa kirjasto", + "menu.file.open_recent_library": "Avaa viimeaikainen", + "menu.file.save_library": "Tallenna kirjasto", + "menu.help": "&Help", + "menu.help.about": "Tietoa", + "menu.macros": "&Macros", + "menu.macros.folders_to_tags": "Kansiot tunnisteiksi", + "menu.select": "Valitse", + "menu.settings": "Asetukset...", + "menu.tools": "&Tools", + "menu.view": "&View", + "menu.view.decrease_thumbnail_size": "Pienennä pienoiskuvakkeen kokoa", + "menu.view.increase_thumbnail_size": "Kasvata pienoiskuvakkeen kokoa", + "menu.view.library_info": "Kirjasto &Information", + "menu.window": "Ikkuna", + "namespace.create.description": "TagStudio käyttää nimiavaruuksia erottaakseen ryhmiä, kuten tunnisteita ja värejä, tavalla, joka helpottaa niiden viemistä ja jakamista. TagStudio varaa \"tagstudio\"-alkuiset nimiavaruudet sisäiseen käyttöön.", + "namespace.create.title": "Luo nimiavaruus", + "namespace.new.button": "Uusi nimiavaruus", + "preview.ignored": "Ohitettu", + "preview.multiple_selection": "{count} Kohdetta valittu", + "preview.no_selection": "Ei kohteita valittuna", + "select.add_tag_to_selected": "Lisää tunniste valittuihin", + "select.all": "Valitse kaikki", + "select.clear": "Poista valinnat", + "select.inverse": "Käännä valinta", + "settings.clear_thumb_cache.title": "Puhdista pienoiskuvien välimuisti", + "settings.dateformat.english": "Englanti", + "settings.dateformat.international": "Kansainvälinen", + "settings.dateformat.label": "Päiväys formaatti", + "settings.dateformat.system": "Järjestelmä", + "settings.filepath.label": "Tiedostopolun näkyvyys", + "settings.filepath.option.full": "Näytä kokonaiset polut", + "settings.filepath.option.name": "Näytä ainoastaan tiedostojen nimet", + "settings.generate_thumbs": "Pienoiskuvan luonti", + "settings.global": "Yleiset asetukset", + "settings.hourformat.label": "24 Tunnin aika", + "settings.infinite_scroll": "Loputon selaaminen", + "settings.language": "Kieli", + "settings.library": "Kirjasto asetukset", + "settings.open_library_on_start": "Avaa kirjasto aloittaaksesi", + "settings.page_size": "Sivun koko", + "settings.show_filenames_in_grid": "Näytä tiedostojen nimet ruudukossa", + "settings.show_recent_libraries": "Näytä viimeaikaiset kirjastot", + "settings.splash.label": "Aloitusnäyttö", + "settings.splash.option.classic": "Classic (9.0)", + "settings.splash.option.default": "Vakio", + "settings.splash.option.goo_gears": "Open Source (9.4)", + "settings.splash.option.ninety_five": "'95 (9.5)", + "settings.splash.option.random": "Satunnainen", + "settings.tag_click_action.add_to_search": "Lisää tunniste hakuun", + "settings.tag_click_action.label": "Tunnisten klikkaus toiminto", + "settings.tag_click_action.open_edit": "Muokkaa tunnistetta", + "settings.tag_click_action.set_search": "Etsi tunnistetta", + "settings.theme.dark": "Tumma", + "settings.theme.label": "Teema:", + "settings.theme.light": "Vaalea", + "settings.theme.system": "Järjestelmä", + "settings.thumb_cache_size.label": "Pienoiskuvakkeiden välimuistin koko", + "settings.title": "Asetukset", + "sorting.direction.ascending": "Nouseva", + "sorting.direction.descending": "Laskeva", + "sorting.mode.random": "Satunnainen", + "splash.opening_library": "Avataan kirjastoa \"{library_path}\"...", + "status.deleted_file_plural": "Poistettiin {count} tiedostoa!", + "status.deleted_file_singular": "Poistettiin 1 tiedosto!", + "status.library_backup_in_progress": "Tallennetaan kirjaston varmuuskopiota...", + "status.library_closed": "Kirjasto suljettu ({time_span})", + "status.library_closing": "Suljetaan kirjastoa...", + "status.library_save_success": "Kirjasto tallennettu ja suljettu!", + "status.library_search_query": "Haetaan kirjastoa...", + "status.library_version_expected": "Odotettu:", + "status.library_version_found": "Löytyi:", + "status.library_version_mismatch": "Kirjasto versio ei vastaa!", + "status.results": "Tulokset", + "tag.add": "Lisää tunniste", + "tag.add.plural": "Lisää tunnisteet", + "tag.add_to_search": "Lisää hakuun", + "tag.aliases": "Aliakset", + "tag.all_tags": "Kaikki tunnisteet", + "tag.choose_color": "Valitse tunnisteen väri", + "tag.color": "Väri", + "tag.create": "Luo tunniste", + "tag.edit": "Muokkaa tunnistetta", + "tag.is_category": "On kategoria", + "tag.is_hidden": "On piilotettu", + "tag.name": "Nimi", + "tag.new": "Uusi tunniste", + "tag.parent_tags": "Ylätunnisteet", + "tag.parent_tags.add": "Lisää ylätunniste(ita)", + "tag.remove": "Poista tunniste", + "tag.search_for_tag": "Etsi tunnistetta", + "tag.shorthand": "Lyhenne", + "tag.tag_name_required": "Tunnisteen nimi (Vaaditaan)", + "tag.view_limit": "Näytä raja:", + "tag_manager.title": "Kirjasto tunnisteet", + "trash.dialog.title.plural": "Poista tiedostoja", + "trash.dialog.title.singular": "Poista tiedosto", + "trash.name.generic": "Roskakori", + "trash.name.windows": "Roskakori", + "version_modal.description": "TagStudion uusi versio on saatavilla! Voit ladata uusimman version Githubista.", + "version_modal.status": "Asennettu versio: {installed_version}
      Uusin julkaisuversio: {latest_release_version}", + "version_modal.title": "TagStudio päivitys saatavilla", + "view.size.0": "Mini", + "view.size.1": "Pieni", + "view.size.2": "Keskikokoinen", + "view.size.3": "Iso", + "view.size.4": "Extra iso", + "window.message.error_opening_library": "Virhe kirjastoa avatessa.", + "window.title.error": "Virhe", + "window.title.open_create_library": "Avaa/Luo kirjasto" +} diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index 68c2df241..8f846be8a 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -140,8 +140,6 @@ "json_migration.heading.aliases": "Mga alyas:", "json_migration.heading.colors": "Mga kulay:", "json_migration.heading.differ": "May pagkakaiba", - "json_migration.heading.extension_list_type": "Uri ng Listahan ng Extension:", - "json_migration.heading.file_extension_list": "Listahan ng Mga File Extension:", "json_migration.heading.match": "Tumutugma", "json_migration.heading.names": "Mga pangalan:", "json_migration.heading.parent_tags": "Mga parent tag:", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index a42c0d385..d3dec09bd 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -1,9 +1,9 @@ { "about.config_path": "Chemin de Configuration", - "about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui mets en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.", + "about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui met en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.", "about.documentation": "Documentation", "about.license": "Licence", - "about.module.found": "Trouver", + "about.module.found": "Trouvé", "about.title": "À propos de TagStudio", "about.website": "Site Internet", "app.git": "Git Commit", @@ -12,10 +12,10 @@ "color.color_border": "Utiliser la couleur secondaire sur la bordure", "color.confirm_delete": "Voulez vous vraiment supprimer la couleur \"{color_name}\"?", "color.delete": "Supprimer le Tag", - "color.import_pack": "Importer un Pack de Couleur", + "color.import_pack": "Importer un Pack de Couleurs", "color.name": "Nom", - "color.namespace.delete.prompt": "Voulez-vous vraiment supprimer ce groupe de couleurs? Cela supprimera TOUTES les couleurs du groupe!", - "color.namespace.delete.title": "Supprimer le namespace de couleur", + "color.namespace.delete.prompt": "Voulez-vous vraiment supprimer ce groupe de couleurs ? Cela supprimera TOUTES les couleurs du groupe !", + "color.namespace.delete.title": "Supprimer l'espace de noms de couleurs", "color.new": "Nouvelle couleur", "color.placeholder": "Couleur", "color.primary": "Couleur Primaire", @@ -33,8 +33,8 @@ "drop_import.progress.window_title": "Importer des Fichiers", "drop_import.title": "Fichier(s) en Conflit", "edit.color_manager": "Gérer la Couleur des Tags", - "edit.copy_fields": "Copier les Fields", - "edit.paste_fields": "Coller les Fields", + "edit.copy_fields": "Copier les Champs", + "edit.paste_fields": "Coller les Champs", "edit.tag_manager": "Gérer les Tags", "entries.duplicate.merge": "Fusion des entrées dupliquées", "entries.duplicate.merge.label": "Fusionner les entrées dupliquées...", @@ -42,19 +42,19 @@ "entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les tags et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.", "entries.generic.refresh_alt": "&Recharger", "entries.generic.remove.removing": "Suppression des Entrées", - "entries.generic.remove.removing_count": "Suppressions de {count} entrées...", + "entries.generic.remove.removing_count": "Suppression de {count} entrées...", "entries.ignored.description": "Les entrées de fichier sont considérées comme « ignorées » si elles ont été ajoutées à la bibliothèque avant que les règles d'ignorance de l'utilisateur (via le fichier « .ts_ignore ») aient été mises à jour pour les exclure. Les fichiers ignorés sont conservés dans la bibliothèque par défaut afin d'éviter toute perte accidentelle de données lors de la mise à jour des règles d'ignorance.", "entries.ignored.ignored_count": "Entrées Ignorées : {count}", "entries.ignored.remove": "Supprimer les entrées ignorées", "entries.ignored.remove_alt": "Supprim&er les entrées ignorées", "entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque...", "entries.ignored.title": "Corriger les entrées ignorées", - "entries.mirror": "&Refléter", - "entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes ?", + "entries.mirror": "&Répliquer", + "entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes ?", "entries.mirror.label": "Réplication de {idx}/{total} Entrées...", "entries.mirror.title": "Réplication des Entrées", - "entries.mirror.window_title": "Entrée Miroir", - "entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes ? Aucun fichiers sur votre disque ne sera supprimée.", + "entries.mirror.window_title": "Entrées Répliqués", + "entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les {count} entrées suivantes ? Aucun fichier sur votre disque ne sera supprimée.", "entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.", "entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...", "entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier", @@ -68,7 +68,7 @@ "entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...", "entries.unlinked.search_and_relink": "&Rechercher && Relier", "entries.unlinked.title": "Réparation des Entrées non Liées", - "entries.unlinked.unlinked_count": "Entrées non Liées : {count}", + "entries.unlinked.unlinked_count": "Entrées non Liées : {count}", "ffmpeg.missing.description": "FFmpeg et/ou FFprobe n’ont pas été trouvée. FFmpeg est nécessaire pour la lecture de média et les vignettes.", "ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}
      {ffprobe} : {ffprobe_status}", "field.copy": "Copier le Champ", @@ -83,25 +83,25 @@ "file.duplicates.dupeguru.file_extension": "Fichiers DupeGuru (*.dupeguru)", "file.duplicates.dupeguru.load_file": "&Charger un Fichier DupeGuru", "file.duplicates.dupeguru.no_file": "Aucun Fichier DupeGuru Sélectionné", - "file.duplicates.dupeguru.open_file": "Ouvrire les Fichiers de Résultats de DupeGuru", + "file.duplicates.dupeguru.open_file": "Ouvrir les Fichiers de Résultats de DupeGuru", "file.duplicates.fix": "Réparer les Fichiers en Double", "file.duplicates.matches": "Dupliquer les Correspondances de Fichier: {count}", - "file.duplicates.matches_uninitialized": "Dupliquer les Correspondances de Fichier : N/A", + "file.duplicates.matches_uninitialized": "Dupliquer les Correspondances de Fichier : N/A", "file.duplicates.mirror.description": "Repliquer les données d'entrée dans chaque jeu de correspondances en double, en combinant toutes les données sans supprimer ni dupliquer de champs. Cette opération ne supprime aucun fichier ni aucune donnée.", "file.duplicates.mirror_entries": "&Répliquer les Entrées", "file.duration": "Durée", "file.not_found": "Fichier non trouvé", "file.open_file": "Ouvrir un Fichier", "file.open_file_with": "Ouvrir le fichier avec", - "file.open_location.generic": "Ouvrir le Fichier dans l'Explorateur de Fichier", + "file.open_location.generic": "Montrer le fichier dans l'Explorateur de Fichier", "file.open_location.mac": "Montrer dans le Finder", "file.open_location.windows": "Montrer dans l'explorateur de Fichiers", "file.path": "Chemin du Fichier", "folders_to_tags.close_all": "Tout Fermer", "folders_to_tags.converting": "Conversion des dossiers en Tags", - "folders_to_tags.description": "Créé des Tags basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.", + "folders_to_tags.description": "Créé des Tags basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les Tags qui seront créés et à quelles entrées ils seront appliqués.", "folders_to_tags.open_all": "Tout Ouvrir", - "folders_to_tags.title": "Créer un Label à partir d'un Dossier", + "folders_to_tags.title": "Créer un Tag à partir d'un Dossier", "generic.add": "Ajouter", "generic.apply": "Appliquer", "generic.apply_alt": "&Appliquer", @@ -115,7 +115,7 @@ "generic.delete_alt": "&Supprimer", "generic.done": "Terminé", "generic.done_alt": "&Terminé", - "generic.edit": "Éditer", + "generic.edit": "Modifier", "generic.edit_alt": "&Modifier", "generic.filename": "Nom de fichier", "generic.missing": "Manquant", @@ -140,6 +140,7 @@ "home.search_entries": "Recherche", "home.search_library": "Rechercher dans la Bibliothèque", "home.search_tags": "Recherche de Tags", + "home.show_hidden_entries": "Afficher les entrées cachées", "home.thumbnail_size": "Taille de la miniature", "home.thumbnail_size.extra_large": "Très Grandes Miniatures", "home.thumbnail_size.large": "Grandes Miniatures", @@ -149,15 +150,14 @@ "ignore.open_file": "Afficher le fichier \"{ts_ignore}\" sur le Disque", "json_migration.checking_for_parity": "Vérification de la Parité...", "json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...", - "json_migration.description": "
      Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie ne sera utilisée que si vous cliquez sur \"Terminer la migration\".

      Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"(!)\" à côté d'elles.
      Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.
      ", - "json_migration.discrepancies_found": "Divergence Détectées dans la Bibliothèque", + "json_migration.description": "
      Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie ne sera utilisée que si vous cliquez sur \"Terminer la migration\".

      Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"(!)\" à côté d'elles.
      Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.
      ", + "json_migration.discrepancies_found": "Divergences Détectées dans la Bibliothèque", "json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.", "json_migration.finish_migration": "Terminer la Migration", "json_migration.heading.aliases": "Alias :", "json_migration.heading.colors": "Couleurs :", "json_migration.heading.differ": "Divergence", - "json_migration.heading.extension_list_type": "Type de liste d'extension :", - "json_migration.heading.file_extension_list": "Liste des extensions de fichiers :", + "json_migration.heading.extensions": "Extensions:", "json_migration.heading.match": "Correspondant", "json_migration.heading.names": "Noms :", "json_migration.heading.parent_tags": "Tags Parents :", @@ -325,6 +325,7 @@ "tag.disambiguation.tooltip": "Utilisez ce Tag pour définir une ambiguïté", "tag.edit": "Modifier un Tag", "tag.is_category": "Est une Catégorie", + "tag.is_hidden": "Est cachée", "tag.name": "Nom", "tag.new": "Nouveau Tag", "tag.parent_tags": "Tags Parent", @@ -348,6 +349,9 @@ "trash.dialog.title.singular": "Supprimer le Fichier", "trash.name.generic": "Poubelle", "trash.name.windows": "Corbeille", + "version_modal.description": "Une nouvelle version de TagStudio est disponible! Vous pouvez télécharger la version la plus récente sur Github.", + "version_modal.status": "Version installer : {installed_version}
      Dernière version disponible : {latest_release_version}", + "version_modal.title": "Mise à jour de TagStudio disponible", "view.size.0": "Mini", "view.size.1": "Petit", "view.size.2": "Moyen", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index 3241641e7..f22b6c3aa 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -33,7 +33,7 @@ "drop_import.progress.window_title": "Fájlok importálása", "drop_import.title": "Fájlütközés", "edit.color_manager": "&Színek kezelése", - "edit.copy_fields": "Mezők &másolása", + "edit.copy_fields": "Mezők másolása", "edit.paste_fields": "Mezők &beillesztése", "edit.tag_manager": "Címkék kezelése", "entries.duplicate.merge": "Egyező elemek &egyesítése", @@ -74,6 +74,9 @@ "field.copy": "Mező &másolása", "field.edit": "Mező szerkesztése", "field.paste": "Mező &beillesztése", + "field_type.datetime": "Dátum és idő", + "field_type.text": "Szöveg", + "field_type.unknown": "Ismeretlen típus", "file.date_added": "Adatbázisba felvétel dátuma", "file.date_created": "Létrehozás dátuma", "file.date_modified": "Módosítás dátuma", @@ -140,6 +143,7 @@ "home.search_entries": "Tételek keresése", "home.search_library": "Keresés a könyvtárban", "home.search_tags": "Címkék keresése", + "home.show_hidden_entries": "Rejtett elemel megjelenítése", "home.thumbnail_size": "Miniatűrök mérete", "home.thumbnail_size.extra_large": "Extra nagy miniatűrök", "home.thumbnail_size.large": "Nagy miniatűrök", @@ -156,8 +160,7 @@ "json_migration.heading.aliases": "Áljelek:", "json_migration.heading.colors": "Színek:", "json_migration.heading.differ": "Eltérés", - "json_migration.heading.extension_list_type": "Kiterjesztési lista típusa:", - "json_migration.heading.file_extension_list": "Fájlkiterjesztési lista:", + "json_migration.heading.extensions": "Kiterjesztések:", "json_migration.heading.match": "Egységesítve", "json_migration.heading.names": "Nevek:", "json_migration.heading.parent_tags": "Szülőcímkék:", @@ -211,7 +214,7 @@ "menu.delete_selected_files_singular": "Fájl {trash_term} &helyezése", "menu.edit": "S&zerkesztés", "menu.edit.ignore_files": "Fájlok és mappák figyelmen kívül hagyása", - "menu.edit.manage_tags": "&Címkék ke&zelése", + "menu.edit.manage_tags": "Címkék kezelése", "menu.edit.new_tag": "Ú&j címke", "menu.file": "&Fájl", "menu.file.clear_recent_libraries": "&Legutóbbi könyvtárak listájának törlése", @@ -272,6 +275,7 @@ "settings.open_library_on_start": "&Könyvtár megnyitása a program indulásakor", "settings.page_size": "&Oldalméret", "settings.restart_required": "A módosítások érvénybeléptetéséhez
      újra kell indítani a TagStudiót.", + "settings.scan_files_on_open": "Új fájlok automatikus betöltése", "settings.show_filenames_in_grid": "&Fájlnevek megjelenítése rácsnézetben", "settings.show_recent_libraries": "&Legutóbbi könyvtárak megjelenítése", "settings.splash.label": "Indítókép", @@ -325,6 +329,7 @@ "tag.disambiguation.tooltip": "Címke használata egyértelműsítéshez", "tag.edit": "Címke szerkesztése", "tag.is_category": "Kategória", + "tag.is_hidden": "Rejtett", "tag.name": "Név", "tag.new": "Új címke", "tag.parent_tags": "Szülőcímkék", @@ -348,6 +353,9 @@ "trash.dialog.title.singular": "Fájl törlése", "trash.name.generic": "kukába", "trash.name.windows": "lomtárba", + "version_modal.description": "Elérhetővé vált egy TagStudio-frissítés. A legújabb verziót a Githubról töltheti le.", + "version_modal.status": "Telepített verzió: {installed_version}
      Legújabb stabil verzió: {latest_release_version}", + "version_modal.title": "TagStudio-frissítés", "view.size.0": "Apró", "view.size.1": "Kicsi", "view.size.2": "Közepes", diff --git a/src/tagstudio/resources/translations/is.json b/src/tagstudio/resources/translations/is.json new file mode 100644 index 000000000..d9d6e4440 --- /dev/null +++ b/src/tagstudio/resources/translations/is.json @@ -0,0 +1,42 @@ +{ + "about.config_path": "Stillingarslóð", + "about.description": "TagStudio er forrit sem skipuleggur og heldur utan um myndir og skrár í gegnum merkja kerfi, sem einblínir á frelsi og sveigjanleika fyrir notandann. Enginn séreigna-hugbúnaður eða skráarsnið, engar aukaskrár, og engin enduruppröðun á skráarkerfinu þínu í heild.", + "about.documentation": "Skjölun", + "about.license": "Leyfi", + "about.module.found": "Fannst", + "about.title": "Um TagStudio", + "about.website": "Vefsíða", + "app.git": "Git Commit", + "app.pre_release": "Forútgáfa", + "app.title": "{base_title} - Safn '{library_dir}'", + "color.color_border": "Nota aukalit fyrir jaðar", + "color.confirm_delete": "Ertu viss um að þú viljir eyða litnum \"{color_name}\"?", + "color.delete": "Eyða Merki", + "color.import_pack": "Flytja inn Litapakka", + "color.name": "Nafn", + "color.namespace.delete.prompt": "Ertu viss um að þú viljir eyða þessu litanafnrými? Þetta mun eyða ÖLLUM litum sem deila því nafnrými!", + "color.namespace.delete.title": "Eyða Litanafnrými", + "color.new": "Nýr Litur", + "color.placeholder": "Litur", + "color.primary": "Aðal Litur", + "color.primary_required": "Aðal Litur (Nauðsynlegt)", + "color.secondary": "Aukalitur", + "color.title.no_color": "Enginn Litur", + "color_manager.title": "Stjórna litum Merkja", + "dependency.missing.title": "{dependency} Fannst Ekki", + "drop_import.description": "Eftirfarandi skrár passa við skráarslóðir sem eru nú þegar í safninu", + "drop_import.duplicates_choice.plural": "Eftirfarandi {count} skrár passa við skráarslóðir sem eru þegar til í safninu.", + "drop_import.duplicates_choice.singular": "Eftirfarandi skrá passar við skráarslóð sem er þegar til í safninu.", + "drop_import.progress.label.initial": "Flyt inn nýjar skrár...", + "drop_import.progress.label.plural": "Flyt inn nýjar skrár...\n{count} Skrár fluttar inn.{suffix}", + "drop_import.progress.label.singular": "Flyt inn nýjar skrár...\n1 Skrá flutt inn.{suffix}", + "drop_import.progress.window_title": "Flytja inn Skrár", + "drop_import.title": "Áreksur Skráa(r)", + "edit.color_manager": "Stjórna litum Merkja", + "edit.copy_fields": "Afrita Reiti", + "edit.paste_fields": "Líma Reiti", + "edit.tag_manager": "Stjórna Merkjum", + "entries.duplicate.merge": "Sameina tvífaldar skráningar", + "entries.duplicate.merge.label": "Sameina tvífaldar skráningar...", + "entries.duplicate.refresh": "Endurhlaða tvíföldum skráningum" +} diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index af4d82386..ebdd413c5 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -87,6 +87,7 @@ "file.duplicates.fix": "Corregi File Duplicati", "file.duplicates.matches": "File Duplicati Corrispondenti: {count}", "file.duplicates.matches_uninitialized": "File Duplicati Corrispondenti: N/A", + "file.duplicates.mirror.description": "Replica i dati delle Voci su ogni insieme di corrispondenze duplicate, combinando tutti i dati senza rimuovere o duplicare i campi. Questa operazione non eliminerà alcun file o dato.", "file.duplicates.mirror_entries": "&Replica Voci", "file.duration": "Lunghezza", "file.not_found": "File Non Trovato", @@ -127,7 +128,7 @@ "generic.paste": "Incolla", "generic.recent_libraries": "Biblioteche Recenti", "generic.remove": "Rimuovi", - "generic.remove_alt": "&RImuovi", + "generic.remove_alt": "&Rimuovi", "generic.rename": "Rinomina", "generic.rename_alt": "&Rinomina", "generic.reset": "Ripristina", @@ -139,6 +140,7 @@ "home.search_entries": "Cerca Voci", "home.search_library": "Cerca Biblioteca", "home.search_tags": "Cerca Etichette", + "home.show_hidden_entries": "Mostra Voci Nascoste", "home.thumbnail_size": "Dimensione Miniature", "home.thumbnail_size.extra_large": "Miniature Molto Grandi", "home.thumbnail_size.large": "Miniature Grandi", @@ -155,13 +157,12 @@ "json_migration.heading.aliases": "Alias:", "json_migration.heading.colors": "Colori:", "json_migration.heading.differ": "Discrepanze", - "json_migration.heading.extension_list_type": "Tipo di Lista di Entensioni:", - "json_migration.heading.file_extension_list": "Elenco Estensioni dei File:", "json_migration.heading.match": "Abbinato", "json_migration.heading.names": "Nomi:", - "json_migration.heading.parent_tags": "Etichette Padre:", + "json_migration.heading.parent_tags": "Etichette Genitore:", "json_migration.heading.paths": "Percorsi:", "json_migration.heading.shorthands": "Abbreviazioni:", + "json_migration.info.description": "File di salvataggio della biblioteca creati con TagStudio versione 9.4 e inferiore dovranno essere migrati al nuovo formato v9.5+.

      Cosa devi sapere:

      • Il tuo file di salvataggio esistente NON verrà eliminato
      • I tuoi file personali NON verranno eliminati, spostati, o modificati
      • Il nuovo formato di salvataggio v9.5+ non può essere aperto con versioni precedenti di TagStudio

      Cosa è cambiato:

      • I \"Campi di Etichette\" sono stati rimpiazzati da \"Categorie di Etichette\". Invece di aggiungere prima etichette ai campi, le etichette vengono ora aggiunte direttamente alle voci dei file. Vengono poi organizzate automaticamente in categorie in base alle etichette genitore contrassegnate con la nuva proprietà \"È Categoria\" nel menu di modifica delle etichette. Qualsiasi etichetta può essere contrassegnata come categoria, e le etichette figlie si ordineranno da sole sotto le etichette genitore contrassegnate come categoria. Le etichette \"Preferito\" e \"Archiviato\" ereditano ora dalla nuova etichetta \"Etichette Meta\" che è contrassegnata come una categoria per impostazione predefinita.
      • I colori delle etichette sono stati modificati e ampliati. Alcuni colori sono stati rinominati o consolidati, però tutti i colori delle etichette continueranno ad essere convertibili in corrispondenze esatte o simili nella versione v9.5.
        ", "json_migration.migrating_files_entries": "Migrando {entries:,d} Voci di File...", "json_migration.migration_complete": "Migrazione Completata!", "json_migration.migration_complete_with_discrepancies": "Migrazione Completata, Discrepanze Rilevate", @@ -185,6 +186,7 @@ "library_info.cleanup.backups": "Backup della Biblioteca:", "library_info.cleanup.dupe_files": "File Duplicati:", "library_info.cleanup.ignored": "Voci Ignorate:", + "library_info.cleanup.legacy_json": "Residui Biblioteca Legacy:", "library_info.cleanup.unlinked": "Voci non Collegate:", "library_info.stats": "Statistiche", "library_info.stats.colors": "Colori Etichette:", @@ -220,7 +222,7 @@ "menu.file.open_create_library": "&Apri/Crea Biblioteca", "menu.file.open_library": "Apri Biblioteca", "menu.file.open_recent_library": "Apri Recenti", - "menu.file.refresh_directories": "&Aggiorna Cartelle", + "menu.file.refresh_directories": "Aggiorna Cartelle", "menu.file.save_backup": "&Salva Backup della Biblioteca", "menu.file.save_library": "Salva Biblioteca", "menu.help": "&Aiuto", @@ -287,6 +289,7 @@ "settings.theme.system": "Sistema", "settings.thumb_cache_size.label": "Dimensione Cache delle Miniature", "settings.title": "Impostazioni", + "settings.zeropadding.label": "Riempimento con zeri delle date", "sorting.direction.ascending": "Ascendente", "sorting.direction.descending": "Discendente", "sorting.mode.random": "Casuale", @@ -321,10 +324,12 @@ "tag.disambiguation.tooltip": "Usa questa etichetta per la disambiguazione", "tag.edit": "Modifica Etichetta", "tag.is_category": "È Categoria", + "tag.is_hidden": "È Nascosta", "tag.name": "Nome", "tag.new": "Nuova Etichetta", - "tag.parent_tags.add": "Aggiungi Etichette Padre", - "tag.parent_tags.description": "Questa etichetta può essere considerata come sostitutiva di qualunque di queste Etichette Padre nelle richerche.", + "tag.parent_tags": "Etichette Genitore", + "tag.parent_tags.add": "Aggiungi Etichette Genitore", + "tag.parent_tags.description": "Questa etichetta può essere considerata come sostitutiva di qualunque di queste Etichette Genitore nelle richerche.", "tag.remove": "Rimuovi Etichetta", "tag.search_for_tag": "Cerca Etichetta", "tag.shorthand": "Abbreviazione", @@ -343,6 +348,9 @@ "trash.dialog.title.singular": "Elimina File", "trash.name.generic": "Spazzatura", "trash.name.windows": "Cestino", + "version_modal.description": "Una nuova versione di TagStudio è disponibile! Puoi scaricare l'ultima versione da Github.", + "version_modal.status": "Versione Installata: {installed_version}
        Ultima Versione Rilasciata: {latest_release_version}", + "version_modal.title": "Aggiornamento di TagStudio Disponibile", "view.size.0": "Mini", "view.size.1": "Piccolo", "view.size.2": "Medio", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 5abde6c2c..2e5eb12d0 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -140,6 +140,7 @@ "home.search_entries": "エントリを検索", "home.search_library": "ライブラリを検索", "home.search_tags": "タグを検索", + "home.show_hidden_entries": "非表示のエントリを表示", "home.thumbnail_size": "サムネイルのサイズ", "home.thumbnail_size.extra_large": "特大サムネイル", "home.thumbnail_size.large": "大サムネイル", @@ -156,8 +157,6 @@ "json_migration.heading.aliases": "エイリアス:", "json_migration.heading.colors": "色:", "json_migration.heading.differ": "差異", - "json_migration.heading.extension_list_type": "拡張子リストの種類:", - "json_migration.heading.file_extension_list": "ファイルの拡張子リスト:", "json_migration.heading.match": "一致", "json_migration.heading.names": "名前:", "json_migration.heading.parent_tags": "親タグ:", @@ -325,6 +324,7 @@ "tag.disambiguation.tooltip": "このタグは曖昧さを解消するために使用されます", "tag.edit": "タグの編集", "tag.is_category": "カテゴリとして扱う", + "tag.is_hidden": "非表示", "tag.name": "名前", "tag.new": "新しいタグ", "tag.parent_tags": "親タグ", @@ -348,6 +348,9 @@ "trash.dialog.title.singular": "ファイルの削除", "trash.name.generic": "ごみ箱", "trash.name.windows": "ごみ箱", + "version_modal.description": "TagStudio の新しいバージョンが利用できます。GitHub から最新リリースをダウンロードできます。", + "version_modal.status": "インストール済みのバージョン: {installed_version}
        最新リリースのバージョン: {latest_release_version}", + "version_modal.title": "TagStudio の更新があります", "view.size.0": "極小", "view.size.1": "小", "view.size.2": "中", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index 35a6f1064..a92b5727e 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -148,8 +148,6 @@ "json_migration.heading.aliases": "Alternative navn:", "json_migration.heading.colors": "Farger:", "json_migration.heading.differ": "Avvik", - "json_migration.heading.extension_list_type": "Type av Utvidelsesliste:", - "json_migration.heading.file_extension_list": "Filutvidelse Liste:", "json_migration.heading.match": "Matchet", "json_migration.heading.names": "Navn:", "json_migration.heading.parent_tags": "Overordnede Etiketter:", @@ -195,7 +193,7 @@ "menu.edit.new_tag": "Ny &Etikett", "menu.file": "Fil", "menu.file.clear_recent_libraries": "Fjern Nylige", - "menu.file.close_library": "&Lukk Bibliotek", + "menu.file.close_library": "Lukk Bibliotek", "menu.file.missing_library.message": "Plasseringen til biblioteket \"{library}\" kan ikke finnes.", "menu.file.missing_library.title": "Manglende Bibliotek", "menu.file.new_library": "Nytt Bibliotek", @@ -212,7 +210,7 @@ "menu.select": "Velg", "menu.settings": "Innstillinger...", "menu.tools": "Verktøy", - "menu.tools.fix_duplicate_files": "Fiks Duplikate &Filer", + "menu.tools.fix_duplicate_files": "Fiks Duplikate Filer", "menu.tools.fix_unlinked_entries": "Fiks &Frakoblede Oppføringer", "menu.view": "&Se", "menu.window": "Vindu", diff --git a/src/tagstudio/resources/translations/nl.json b/src/tagstudio/resources/translations/nl.json index ec5836a3b..bb2ad4368 100644 --- a/src/tagstudio/resources/translations/nl.json +++ b/src/tagstudio/resources/translations/nl.json @@ -1,5 +1,6 @@ { "about.config_path": "Configuratie Pad", + "about.description": "TagStudio is een applicatie om foto's en bestanden te organiseren, met een onderliggend systeem gebaseerd op tags, dat zich focust op vrijheid en flexibiliteit bieden aan de gebruiker. Geen gepatenteerde programma's of bestandsformaten, geen zeëen aan sidecarbestanden, en je bestaande bestandsstructuur wordt niet volledig overhoop gegooid.", "about.documentation": "Documentatie", "about.license": "Licentie", "about.module.found": "Gevonden", @@ -7,8 +8,11 @@ "about.website": "Website", "app.git": "Git Commit", "app.pre_release": "Pre-Release", + "app.title": "{base_title} - Bibliotheek '{library_dir}'", + "color.color_border": "Gebruik Secundaire Kleur voor Rand", "color.confirm_delete": "Weet u zeker dat u de kleur \"{color_name}\" wilt verwijderen?", "color.delete": "Verwijder Label", + "color.import_pack": "Importeer Kleurenpakket", "color.name": "Naam", "color.new": "Nieuwe Kleur", "color.placeholder": "Kleur", @@ -18,14 +22,21 @@ "color.title.no_color": "Geen Kleur", "color_manager.title": "Beheer Label Kleuren", "dependency.missing.title": "{dependency} Niet Gevonden", + "drop_import.description": "Volgende bestanden hebben een pad dat overeenkomt met een reeds bestaand pad in de bibliotheek", + "drop_import.duplicates_choice.plural": "De volgende {count} bestanden hebben een pad dat overeenkomt met een reeds bestaand pad in de bibliotheek.", + "drop_import.duplicates_choice.singular": "Volgend bestand heeft een pad dat overeenkomt met een reeds bestaand pad in de bibliotheek.", "drop_import.progress.label.initial": "Nieuwe bestanden importeren…", "drop_import.progress.label.plural": "Nieuwe bestanden importeren…\n{count} bestanden geïmporteerd.{suffix}", "drop_import.progress.label.singular": "Nieuwe bestanden importeren…\n1 bestand geïmporteerd.{suffix}", "drop_import.progress.window_title": "Importeer bestanden", + "drop_import.title": "Conflicterende bestand(en)", "edit.color_manager": "Beheer Label Kleuren", "edit.copy_fields": "Velden Kopiëren", "edit.paste_fields": "Velden Plakken", "edit.tag_manager": "Beheer Labels", + "entries.duplicate.merge": "Dubbele Vermeldingen Samenvoegen", + "entries.duplicate.merge.label": "Dubbele vermeldingen samenvoegen...", + "entries.duplicate.refresh": "Dubbele Invoer Vernieuwen", "entries.tags": "Labels", "field.copy": "Veld Kopiëren", "field.edit": "Veld Aanpassen", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index c83c72eaf..969735613 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -139,8 +139,6 @@ "json_migration.heading.aliases": "Zastępcze nazwy:", "json_migration.heading.colors": "Kolory:", "json_migration.heading.differ": "Niezgodność", - "json_migration.heading.extension_list_type": "Typ listy rozszerzeń:", - "json_migration.heading.file_extension_list": "Lista rozszerzeń plików:", "json_migration.heading.match": "Dopasowane", "json_migration.heading.names": "Nazwy:", "json_migration.heading.parent_tags": "Tagi nadrzędne:", @@ -181,14 +179,14 @@ "menu.edit.new_tag": "Nowy &Tag", "menu.file": "&Plik", "menu.file.clear_recent_libraries": "Wyczyść ostatnie", - "menu.file.close_library": "&Zamknij bibliotekę", + "menu.file.close_library": "Zamknij bibliotekę", "menu.file.missing_library.message": "Lokalizacja biblioteki \"{library}\" nie została odnaleziona.", "menu.file.missing_library.title": "Brakująca biblioteka", "menu.file.new_library": "Nowa biblioteka", "menu.file.open_create_library": "&Otwórz/Stwórz bibliotekę", "menu.file.open_library": "Otwórz bibliotekę", "menu.file.open_recent_library": "Otwórz ostatnie", - "menu.file.refresh_directories": "&Odśwież katalogi", + "menu.file.refresh_directories": "Odśwież katalogi", "menu.file.save_backup": "&Zapisz kopię zapasową biblioteki", "menu.file.save_library": "Zapisz bibliotekę", "menu.help": "&Pomoc", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index cbd27f3a0..4ea3898c6 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -136,8 +136,6 @@ "json_migration.heading.aliases": "Pseudônimos:", "json_migration.heading.colors": "Cores:", "json_migration.heading.differ": "Discrepância", - "json_migration.heading.extension_list_type": "Tipo de Lista de Extensão:", - "json_migration.heading.file_extension_list": "Lista de Extensão de Ficheiro:", "json_migration.heading.match": "Combinado", "json_migration.heading.names": "Nomes:", "json_migration.heading.parent_tags": "Tags Pai:", @@ -181,7 +179,7 @@ "menu.file.open_create_library": "&Abrir/Criar Biblioteca", "menu.file.open_library": "Abrir Biblioteca", "menu.file.open_recent_library": "Abrir Recente", - "menu.file.refresh_directories": "&Atualizar Pastas", + "menu.file.refresh_directories": "Atualizar Pastas", "menu.file.save_backup": "&Gravar Backup da Biblioteca", "menu.file.save_library": "Gravar Biblioteca", "menu.help": "&Ajuda", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index 41ed96df6..623cf7fd5 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -3,7 +3,9 @@ "about.description": "TagStudio é uma aplicação de organização de fotos e arquivos com um sistema de tags que tem como foco conceder liberdade e flexibilidade ao usuário. Sem programas ou formatos proprietários, sem imensidão de arquivos Sidecar, e sem total transtorno de sua estrutura de sistema de arquivos.", "about.documentation": "Documentação", "about.license": "Licença", + "about.module.found": "Encontrado", "about.title": "Sobre", + "about.website": "Site", "app.git": "Confirmação do Git", "app.pre_release": "Pré-Lançamento", "app.title": "{base_title} - Biblioteca '{library_dir}'", @@ -21,6 +23,7 @@ "color.secondary": "Cor Secundária", "color.title.no_color": "Nenhuma Cor", "color_manager.title": "Gerenciar Cores das Tags", + "dependency.missing.title": "{dependency} Não Encontrada", "drop_import.description": "Os seguintes arquivos correspondem a caminhos de arquivos que já existem na biblioteca", "drop_import.duplicates_choice.plural": "Os seguintes arquivos {count} correspondem a caminhos de arquivo que já existem na biblioteca.", "drop_import.duplicates_choice.singular": "O arquivo a seguir corresponde a um caminho de arquivo que já existe na biblioteca.", @@ -37,13 +40,22 @@ "entries.duplicate.merge.label": "Mesclando Itens Duplicados...", "entries.duplicate.refresh": "Atualizar Registros Duplicados", "entries.duplicates.description": "Registros duplicados são definidas como multiplos registros que levam ao mesmo arquivo no disco. Mesclar esses registros irá combinar as tags e metadados de todas as duplicatas em um único registro consolidado. Não confundir com \"Arquivos Duplicados\" que são duplicatas dos seus arquivos fora do TagStudio.", + "entries.generic.refresh_alt": "&Atualizar", "entries.generic.remove.removing": "Deletando Registros", + "entries.generic.remove.removing_count": "Removendo {count} Registros...", + "entries.ignored.description": "Os arquivos são considerados \"ignorados\" se foram adicionados à biblioteca antes que as regras de ignorar do usuário (através do arquivo '.ts_ignore') fossem atualizadas para excluí-los. Os arquivos ignorados são mantidos na biblioteca por padrão para evitar perda acidental de dados ao atualizar as regras de ignorar.", + "entries.ignored.ignored_count": "Registros Ignorados: {count}", + "entries.ignored.remove": "Remover Registros Ignorados", + "entries.ignored.remove_alt": "Remover Entradas Ignoradas", + "entries.ignored.scanning": "Escaneando a Biblioteca por Registros Ignorados", + "entries.ignored.title": "Consertar Registros Ignorados", "entries.mirror": "&Espelho", "entries.mirror.confirmation": "Tem certeza que você deseja espelhar os seguintes {count} registros?", "entries.mirror.label": "Espelhando {idx}/{total} Registros...", "entries.mirror.title": "Espelhando Registros", "entries.mirror.window_title": "Espelhar Registros", "entries.remove.plural.confirm": "Tem certeza que deseja deletar os seguintes {count} Registros ?", + "entries.remove.singular.confirm": "Você tem certeza que deseja remover esse registro da sua bilbioteca ? Nenhum arquivo no disco será excluído.", "entries.running.dialog.new_entries": "Adicionando {total} Novos Registros de Arquivos...", "entries.running.dialog.title": "Adicionando Novos Registros de Arquivos", "entries.tags": "Tags", @@ -51,10 +63,14 @@ "entries.unlinked.relink.attempting": "Tentando referenciar {index}/{unlinked_count} Registros, {fixed_count} Referenciados com Sucesso", "entries.unlinked.relink.manual": "&Referência Manual", "entries.unlinked.relink.title": "Referenciando Registros", + "entries.unlinked.remove": "Remover Registros Não Vinculados", + "entries.unlinked.remove_alt": "Remover Entradas sem Conexões", "entries.unlinked.scanning": "Escaneando bibliotecada em busca de registros não referenciados...", "entries.unlinked.search_and_relink": "&Buscar && Referenciar", "entries.unlinked.title": "Corrigir Registros Não Referenciados", "entries.unlinked.unlinked_count": "Registros Não Referenciados: {count}", + "ffmpeg.missing.description": "FFmpeg e/ou FFprobe não foram encontrados. FFmpeg é necessário para reproduzir multimídias e miniaturas.", + "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
        {ffprobe}: {ffprobe_status}", "field.copy": "Copiar Campo", "field.edit": "Editar Campo", "field.paste": "Colar Campo", @@ -80,6 +96,7 @@ "file.open_location.generic": "Abrir no explorador de arquivos", "file.open_location.mac": "Mostrar no Finder", "file.open_location.windows": "Mostrar no Explorador de Arquivos", + "file.path": "Caminho do Arquivo", "folders_to_tags.close_all": "Fechar Tudo", "folders_to_tags.converting": "Convertendo pastas para Tags", "folders_to_tags.description": "Cria tags com base na sua estrutura de arquivos e aplica elas nos seus registros\nA estrutura abaixo mostra todas as tags que serão criadas e em quais itens elas serão aplicadas.", @@ -101,43 +118,56 @@ "generic.edit": "Editar", "generic.edit_alt": "&Editar", "generic.filename": "Nome do Arquivo", + "generic.missing": "Vazio", "generic.navigation.back": "Anterior", "generic.navigation.next": "Próximo", + "generic.no": "Não", "generic.none": "Nenhum", "generic.overwrite": "Sobrescrever", "generic.overwrite_alt": "&Sobrescrever", "generic.paste": "Colar", "generic.recent_libraries": "Bibliotecas recentes", + "generic.remove": "Remover", + "generic.remove_alt": "&Remover", "generic.rename": "Renomear", "generic.rename_alt": "&Renomear", "generic.reset": "Redefinir", "generic.save": "Salvar", "generic.skip": "Pular", "generic.skip_alt": "&Pular", + "generic.yes": "Sim", "home.search": "Buscar", "home.search_entries": "Buscar Registros", "home.search_library": "Buscar na Biblioteca", "home.search_tags": "Buscar Tags", + "home.show_hidden_entries": "Mostrar Itens Ocultos", "home.thumbnail_size": "Tamanho de miniatura", "home.thumbnail_size.extra_large": "Miniaturas Extra Grandes", "home.thumbnail_size.large": "Miniaturas Grandes", "home.thumbnail_size.medium": "Miniaturas Médias", "home.thumbnail_size.mini": "Miniaturas Mini", "home.thumbnail_size.small": "Miniaturas Pequenas", + "ignore.open_file": "Mostrar \"{ts_ignore}\" Arquivo no Disco", + "json_migration.checking_for_parity": "Verificando a Paridade", "json_migration.creating_database_tables": "Criando Tabelas de Banco de Dados SQL...", + "json_migration.description": "
        Inicie e pré-visualize os resultados do processo de migração da biblioteca. A biblioteca convertida não será usada a menos que você clique em \"Terminar Migração\".

        A informação da biblioteca devem ter valores correspondentes ou ter o rotulo \"Correspondido\". Valores que não tenham correspondência serão mostrados em vermelho e conter um símbolo \"(!)\" próximo a eles.
        Este processo pode demorar alguns minutos para bibliotecas grandes.
        ", "json_migration.discrepancies_found": "Encontradas Discrepâncias na biblioteca", "json_migration.discrepancies_found.description": "Discrepâncias foram encontradas entre os arquivos de Biblioteca originais e os convertidos. Por favor, revise e escolha continuar com a migração ou cancelar.", "json_migration.finish_migration": "Finalizar Migração", "json_migration.heading.aliases": "Pseudônimos:", "json_migration.heading.colors": "Cores:", "json_migration.heading.differ": "Discrepância", - "json_migration.heading.file_extension_list": "Lista de Extensão de Arquivo:", + "json_migration.heading.match": "Correspondido", "json_migration.heading.names": "Nomes:", "json_migration.heading.parent_tags": "Tags Pai:", + "json_migration.heading.paths": "Caminhos:", + "json_migration.heading.shorthands": "Taquigrafias:", + "json_migration.info.description": "Os arquivos de biblioteca salvos criados com as versões do TagStudio 9.4 e anteriores precisarão ser migrados para o novo formato v9.5+.

        O que você precisa saber:

        • Seu arquivo de biblioteca salvo existente NÃO será excluído
        • Seus arquivos pessoais NÃO serão excluídos, movidos ou modificados
        • O novo formato de salvamento v9.5+ não pode ser aberto em versões anteriores do TagStudio

        O que mudou:

        • \"Campos de Tag\" foram substituídos por \"Categorias de Tag\". Em vez de adicionar tags aos campos primeiro, as tags agora são adicionadas diretamente às entradas do arquivo. Elas são então organizadas automaticamente em categorias com base nas tags pai marcadas com a nova propriedade \"É Categoria\" no menu de edição de tags. Qualquer tag pode ser marcada como uma categoria e as tags filhas serão classificadas sob as tags pai marcadas como categorias. As tags \"Favoritos\" e \"Arquivados\" agora herdam de uma nova tag \"Meta Tags\", que é marcada como categoria por padrão.
        • As cores das tags foram ajustadas e expandidas. Algumas cores foram renomeadas ou consolidadas, porém todas as cores das tags ainda serão convertidas para correspondências exatas ou aproximadas na versão 9.5.
          ", "json_migration.migrating_files_entries": "Migrando {entries:,d} Registros de Arquivos...", "json_migration.migration_complete": "Migração Concluída!", "json_migration.migration_complete_with_discrepancies": "Migração Concluída, Discrepâncias Encontradas", "json_migration.start_and_preview": "Iniciar e Visualizar", + "json_migration.title": "Salvar Formato de Migração: \"{path}\"", "json_migration.title.new_lib": "

          Biblioteca v9.5+

          ", "json_migration.title.old_lib": "

          Biblioteca v9.4

          ", "landing.open_create_library": "Abrir/Criar Biblioteca {shortcut}", @@ -152,14 +182,29 @@ "library.refresh.scanning_preparing": "Escaneando Diretórios por Novos Arquivos...\nPreparando...", "library.refresh.title": "Atualizando Pastas", "library.scan_library.title": "Escaneando Biblioteca", + "library_info.cleanup": "Limpeza", + "library_info.cleanup.backups": "Backup de Bibliotecas:", + "library_info.cleanup.dupe_files": "Arquivos Duplicados:", + "library_info.cleanup.ignored": "Registros Ignorados", + "library_info.cleanup.legacy_json": "Sobra da Biblioteca Legada:", + "library_info.cleanup.unlinked": "Registros Desvinculados:", + "library_info.stats": "Estatísticas", + "library_info.stats.colors": "Cores de Etiquetas:", "library_info.stats.entries": "Registros:", "library_info.stats.fields": "Campos:", + "library_info.stats.macros": "Macros:", + "library_info.stats.namespaces": "Namespaces:", "library_info.stats.tags": "Tags:", + "library_info.title": "Biblioteca '{library_dir}'", + "library_info.version": "Formato de Versão da Biblioteca: {version}", "library_object.name": "Nome", "library_object.name_required": "Nome (Obrigatório)", + "library_object.slug": "ID Alternativo", + "library_object.slug_required": "ID Alternativo (obrigatório)", "macros.running.dialog.new_entries": "Executando Macros Configurados nos {count}/{total} Novos Registros de Arquivos...", "macros.running.dialog.title": "Executando Macros nos Novos Registros", "media_player.autoplay": "Tocar Automaticamente", + "media_player.loop": "Repetição", "menu.delete_selected_files_ambiguous": "Mover Arquivo(s) para {trash_term}", "menu.delete_selected_files_plural": "Mover Arquivos para {trash_term}", "menu.delete_selected_files_singular": "Mover Arquivo para {trash_term}", @@ -170,41 +215,87 @@ "menu.file": "&Arquivo", "menu.file.clear_recent_libraries": "Limpar Recentes", "menu.file.close_library": "&Fechar Biblioteca", + "menu.file.missing_library.message": "A localização da biblioteca \"{library}\" não foi encontrada.", + "menu.file.missing_library.title": "Biblioteca Não Encontrada", "menu.file.new_library": "Nova Biblioteca", + "menu.file.open_backups_folder": "Abrir Pasta de Backups", "menu.file.open_create_library": "&Abrir/Criar Biblioteca", "menu.file.open_library": "Abrir Biblioteca", "menu.file.open_recent_library": "Abrir Recente", - "menu.file.refresh_directories": "&Atualizar Pastas", + "menu.file.refresh_directories": "Atualizar Pastas", "menu.file.save_backup": "&Salvar Backup da Biblioteca", "menu.file.save_library": "Salvar Biblioteca", "menu.help": "&Ajuda", "menu.help.about": "Sobre", "menu.macros": "&Macros", "menu.macros.folders_to_tags": "Pastas para Tags", + "menu.select": "Selecionar", "menu.settings": "Configurações...", "menu.tools": "&Ferramentas", "menu.tools.fix_duplicate_files": "Corrigir &Arquivos Duplicados", + "menu.tools.fix_ignored_entries": "Consertar Entradas &Ignoradas", "menu.tools.fix_unlinked_entries": "Corrigir &Registros Não Referenciados", "menu.view": "&Exibir", + "menu.view.decrease_thumbnail_size": "Diminuir Tamanho de Miniatura", + "menu.view.increase_thumbnail_size": "Aumentar Tamanho de Miniatura", + "menu.view.library_info": "&Informação da Biblioteca", "menu.window": "Janela", + "namespace.create.description": "Namespaces são usados pelo TagStudio para separar grupos de items como as etiquetas e cores, de uma forma que os fazem ser fáceis de exportar e compartilhar. Namespaces começam com \"tagstudio\" são reservados pelo TagStudio para uso interno.", + "namespace.create.description_color": "Cor de etiquetas usam namespaces como grupo de paleta de cor. Todas as cores customizadas devem estar primeiro em um grupo de namespace.", + "namespace.create.title": "Criar Namespace", + "namespace.new.button": "Novo Namespace", + "namespace.new.prompt": "Crie um Novo Namespace para Começar a Adicionar Cores Customizadas!", + "preview.ignored": "Ignorado", "preview.multiple_selection": "{count} Itens Selecionados", "preview.no_selection": "Nenhum Item Selecionado", + "preview.unlinked": "Desvinculado", "select.add_tag_to_selected": "Adicionar Tag às Seleções", "select.all": "Selecionar Tudo", "select.clear": "Limpar Seleção", + "select.inverse": "Inverter Seleção", "settings.clear_thumb_cache.title": "Limpar cache de miniaturas", + "settings.dateformat.english": "Inglês", + "settings.dateformat.international": "Internacional", + "settings.dateformat.label": "Formato de Data", + "settings.dateformat.system": "Sistema", + "settings.filepath.label": "Visibilidade do Caminho do Arquivo", + "settings.filepath.option.full": "Mostrar Caminhos Completos", + "settings.filepath.option.name": "Mostrar Apenas Nome de Arquivos", + "settings.filepath.option.relative": "Mostrar Caminhos Relativos", + "settings.generate_thumbs": "Geração de Miniatura", + "settings.global": "Configurações Globais", + "settings.hourformat.label": "Formato em 24 Horas", + "settings.infinite_scroll": "Rolagem Infinita", "settings.language": "Idioma", + "settings.library": "Configurações da Biblioteca", "settings.open_library_on_start": "Abrir Biblioteca ao Iniciar", + "settings.page_size": "Tamanho da Página", "settings.restart_required": "Por favor reinicie o TagStudio para que as mudanças façam efeito.", "settings.show_filenames_in_grid": "Exibir nome dos arquivos", "settings.show_recent_libraries": "Mostrar Bibliotecas Recentes", + "settings.splash.label": "Tela Inicial", + "settings.splash.option.classic": "Clássico (9.0)", + "settings.splash.option.default": "Padrão", + "settings.splash.option.goo_gears": "Código Aberto (9.4)", + "settings.splash.option.random": "Aleatório", + "settings.tag_click_action.add_to_search": "Adicionar Etiqueta à Pesquisa", + "settings.tag_click_action.label": "Ação de Clique da Etiqueta", + "settings.tag_click_action.open_edit": "Editar Etiqueta", + "settings.tag_click_action.set_search": "Pesquisar por Etiqueta", + "settings.theme.dark": "Escuro", + "settings.theme.label": "Tema:", + "settings.theme.light": "Claro", + "settings.theme.system": "Sistema", + "settings.thumb_cache_size.label": "Tamanho de Cache da Miniatura", "settings.title": "Configurações", "sorting.direction.ascending": "Ordem Ascendente", "sorting.direction.descending": "Ordem Descendente", + "sorting.mode.random": "Aleatório", "splash.opening_library": "Abrindo Biblioteca \"{library_path}\"...", "status.deleted_file_plural": "{count} Arquivos Apagados!", "status.deleted_file_singular": "1 Arquivo Apagado!", "status.deleted_none": "Nenhum Arquivo Apagado.", + "status.deleted_partial_warning": "Apenas {count} arquivo(s) excluído(s)! Verifique se algum dos arquivos está faltando ou em uso.", "status.deleting_file": "Apagando arquivo [{i}/{count}]: \"{path}\"...", "status.library_backup_in_progress": "Salvando Backup da Biblioteca...", "status.library_backup_success": "Backup da Biblioteca Salvo em: \"{path}\" ({time_span})", @@ -228,14 +319,18 @@ "tag.confirm_delete": "Tem certeza que quer deletar a tag \"{tag_name}\"?", "tag.create": "Criar Tag", "tag.create_add": "Criar && Adicionar \"{query}\"", + "tag.disambiguation.tooltip": "Use esta etiqueta para desambiguação", "tag.edit": "Editar Tag", "tag.is_category": "É Categoria", + "tag.is_hidden": "Está Oculto", "tag.name": "Nome", "tag.new": "Nova Tag", "tag.parent_tags": "Tags Pai", "tag.parent_tags.add": "Adicionar Tag Pai", + "tag.remove": "Remover Tag", "tag.search_for_tag": "Procurar por Tag", "tag.shorthand": "Abreviação", + "tag.tag_name_required": "Nome da Tag (Obrigatório)", "tag.view_limit": "Limite de visualização:", "tag_manager.title": "Tags da sua biblioteca", "trash.context.ambiguous": "Mover arquivo(s) para {trash_term}", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index 5b68f1480..44a7fb4f3 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -137,8 +137,6 @@ "json_migration.heading.aliases": "Andrnamae:", "json_migration.heading.colors": "Varge:", "json_migration.heading.differ": "Tchigauzma", - "json_migration.heading.extension_list_type": "Fal fu taksanting tumam:", - "json_migration.heading.file_extension_list": "Tumam fu mlafufal:", "json_migration.heading.match": "Finnajena sama", "json_migration.heading.names": "Namae:", "json_migration.heading.parent_tags": "Atama festaretol:", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index 9fa1e9615..4b6eaca0e 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -35,19 +35,25 @@ "edit.color_manager": "Редактировать цвета тегов", "edit.copy_fields": "Копировать поля", "edit.paste_fields": "Вставить поля", - "edit.tag_manager": "Управлять тегами", + "edit.tag_manager": "Управление тегами", "entries.duplicate.merge": "Объединить записи-дубликаты", "entries.duplicate.merge.label": "Объединение записей-дубликатов...", "entries.duplicate.refresh": "Обновить записи-дубликаты", - "entries.duplicates.description": "Записи-дубликаты — это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с несколькими копиями самого файла, которые могут существовать вне TagStudio.", + "entries.duplicates.description": "Записи-дубликаты — это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с копиями самого файла, которые существуют вне TagStudio.", "entries.generic.refresh_alt": "&Обновить", "entries.generic.remove.removing": "Удаление записей", + "entries.generic.remove.removing_count": "Удаление {count} записей...", + "entries.ignored.ignored_count": "Проигнорированных записей: {count}", + "entries.ignored.remove": "Удалить игнорируемые записи", + "entries.ignored.scanning": "Поиск игнорируемых записей...", + "entries.ignored.title": "Исправить игнорируемые записи", "entries.mirror": "&Отзеркалить", "entries.mirror.confirmation": "Вы уверены, что хотите отзеркалить следующие {count} записей?", "entries.mirror.label": "Отзеркаливание {idx}/{total} записей...", "entries.mirror.title": "Отзеркаливание записей", "entries.mirror.window_title": "Отзеркалить записи", - "entries.remove.plural.confirm": "Вы уверены, что хотите удалить {count} записей?", + "entries.remove.plural.confirm": "Вы уверены, что хотите удалить {count} записей? Файлы на диске не будут удалены.", + "entries.remove.singular.confirm": "Вы уверены, что хотите удалить эту запись? Файл на диске не будет удалён.", "entries.running.dialog.new_entries": "Добавление {total} новых записей...", "entries.running.dialog.title": "Добавление новых записей", "entries.tags": "Теги", @@ -55,6 +61,7 @@ "entries.unlinked.relink.attempting": "Попытка перепривязать {index}/{unlinked_count} записей, {fixed_count} привязано успешно", "entries.unlinked.relink.manual": "&Ручная привязка", "entries.unlinked.relink.title": "Привязка записей", + "entries.unlinked.remove": "Удалить откреплённые записи", "entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...", "entries.unlinked.search_and_relink": "&Поиск и привязка", "entries.unlinked.title": "Исправить откреплённые записи", @@ -141,8 +148,6 @@ "json_migration.heading.aliases": "Псевдонимы:", "json_migration.heading.colors": "Цвета:", "json_migration.heading.differ": "Несоответствие", - "json_migration.heading.extension_list_type": "Тип списка расширений:", - "json_migration.heading.file_extension_list": "Список расширений файлов:", "json_migration.heading.match": "Совпало", "json_migration.heading.names": "Имена:", "json_migration.heading.parent_tags": "Родительские теги:", @@ -195,7 +200,7 @@ "menu.file.open_create_library": "&Открыть/создать библиотеку", "menu.file.open_library": "Открыть библиотеку", "menu.file.open_recent_library": "Открыть последнюю", - "menu.file.refresh_directories": "&Обновить папки", + "menu.file.refresh_directories": "Обновить папки", "menu.file.save_backup": "&Сохранить резервную копию библиотеки", "menu.file.save_library": "Сохранить библиотеку", "menu.help": "&Помощь", diff --git a/src/tagstudio/resources/translations/sv.json b/src/tagstudio/resources/translations/sv.json index 5c5ce50c4..bedc3aca1 100644 --- a/src/tagstudio/resources/translations/sv.json +++ b/src/tagstudio/resources/translations/sv.json @@ -1,12 +1,29 @@ { + "about.config_path": "Konfigureringssökväg", + "about.description": "TagStudio är en bild- och filorganiseringsapplikation med ett underliggande etikettbaserat system som fokuserar på att ge frihet och flexibilitet till användaren. Inga proprietära program eller format, inget hav av sidofiler och ingen omstörtning av ditt filsystems struktur.", "about.documentation": "Dokumentation", "about.license": "Licens", "about.module.found": "Hittade", "about.title": "Om TagStudio", "about.website": "Webbsida", + "app.git": "Git Commit", + "app.pre_release": "Förhandsutgåva", "app.title": "{base_title} - Bibliotek '{library_dir}'", + "color.color_border": "Använd Sekundär Färg för Kant", "color.confirm_delete": "Är du säker på att du vill ta bort färgen \"{color_name}\"?", + "color.delete": "Radera Etikett", + "color.import_pack": "Importera Färgpaket", "color.name": "Namn", + "color.namespace.delete.prompt": "Är du säker på att du vill radera denna färgnamnrymd? ALLA färger i namnrymden kommer att raderas med den!", + "color.namespace.delete.title": "Radera Färgnamnrymd", + "color.new": "Ny Färg", + "color.placeholder": "Färg", + "color.primary": "Primärfärg", + "color.primary_required": "Primärfärg (Krävs)", + "color.secondary": "Sekundärfärg", + "color.title.no_color": "Ingen Färg", + "color_manager.title": "Hantera Etikettfärger", + "dependency.missing.title": "{dependency} Inte Funnen", "drop_import.description": "Följande filer har namn som redan finns i biblioteket", "drop_import.duplicates_choice.plural": "Följande {count} filer har namn som redan finns i biblioteket.", "drop_import.duplicates_choice.singular": "Följande fil har ett namn som redan finns i biblioteket.", @@ -15,25 +32,49 @@ "drop_import.progress.label.singular": "Importerar nya filer...\n1 Fil importerad.{suffix}", "drop_import.progress.window_title": "Importera Filer", "drop_import.title": "Konflikterande Filer", + "edit.color_manager": "Hantera Etikettfärger", + "edit.copy_fields": "Kopiera Fält", + "edit.paste_fields": "Klistra In Fält", "edit.tag_manager": "Hantera Etiketter", "entries.duplicate.merge": "Sammanslå Dubbla Poster", "entries.duplicate.merge.label": "Sammanslår dubbla poster...", "entries.duplicate.refresh": "Uppdatera Dubbla Poster", "entries.duplicates.description": "Dubbla poster är definierade som flera poster som pekar på samma fil på datorn. Genom att slå ihop dessa poster kommer deras etiketter och metadata från dubbletterna att kombineras till en post. Dessa ska inte förväxlas med \"dubbla filer\", som är dubbletter av dina filer utanför TagStudio.", + "entries.generic.refresh_alt": "&Uppdatera", + "entries.generic.remove.removing": "Raderar poster", + "entries.generic.remove.removing_count": "Raderar {count} Poster...", + "entries.ignored.description": "Filposter räknas som \"ignorerade\" om de lades till biblioteket innan användarens ignoreringsregler (via '.ts_ignore' filen) uppdaterades för att exkludera det. Ignorerade filer behålls i biblioteket som standard för att förhindra att data förloras av misstag när ignoreringsreglerna uppdateras.", + "entries.ignored.ignored_count": "Ignorerade Poster: {count}", + "entries.ignored.remove": "Ta Bort Ignorerade Poster", + "entries.ignored.remove_alt": "&Ta Bort Ignorerade Poster", + "entries.ignored.scanning": "Skannar Bibliotek efter Ignorerade Poster...", + "entries.ignored.title": "Fixa Ignorerade Poster", "entries.mirror": "Spegla", "entries.mirror.confirmation": "Är du säker att du vill spegla följande {count} poster?", "entries.mirror.label": "Speglar {idx}/{total} poster...", "entries.mirror.title": "Speglar Poster", "entries.mirror.window_title": "Spegla Poster", - "entries.tags": "Etiketter", "entries.remove.plural.confirm": "Är du säker att du vill radera följande {count} poster?", - "entries.generic.remove.removing": "Raderar poster", + "entries.remove.singular.confirm": "Är du säker på att du vill ta bort denna post från ditt bibliotek? Inga filer på disken kommer att raderas.", + "entries.running.dialog.new_entries": "Lägger Till {total} Nya Filposter...", + "entries.running.dialog.title": "Lägger Till Nya Filposter", + "entries.tags": "Etiketter", "entries.unlinked.description": "Varje post i biblioteket är länkad till en fil i en av dina kataloger. Om en fil länkad till en post är flyttad eller borttagen utanför TagStudio blir den olänkad. Olänkade poster kan automatiskt bli omlänkade genom att söka genom dina kataloger, manuellt omlänkade av användaren eller tas bort om så önskas.", + "entries.unlinked.relink.attempting": "Försöker att länka om {index}/{unlinked_count} Poster, {fixed_count} Lyckades Länkas Om", "entries.unlinked.relink.manual": "Länka om manuellt", "entries.unlinked.relink.title": "Länkar om poster", + "entries.unlinked.remove": "Ta Bort Olänkade Poster", + "entries.unlinked.remove_alt": "&Ta Bort Olänkade Poster", "entries.unlinked.scanning": "Skannar bibliotek efter olänkade poster...", "entries.unlinked.search_and_relink": "Sök && Länka om", "entries.unlinked.title": "Fixa olänkade poster", + "entries.unlinked.unlinked_count": "Olänkade Poster: {count}", + "ffmpeg.missing.description": "FFmpeg och/eller FFprobe hittades inte. FFmpeg krävs för uppspelning av multimedia och tumnaglar.", + "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
          {ffprobe}: {ffprobe_status}", + "field.copy": "Kopiera Fält", + "field.edit": "Redigera Fält", + "field.paste": "Klistra In Fält", + "file.date_added": "Datum Tillagd", "file.date_created": "Skapad den", "file.date_modified": "Senast ändrad", "file.dimensions": "Dimensioner", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index 6b57600b3..547ad6387 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -1,10 +1,10 @@ { "about.config_path": "கட்டமைப்பு பாதை", - "about.description": "டேக்ச்டுடியோ என்பது ஒரு புகைப்படம் மற்றும் கோப்பு அமைப்பு பயன்பாடாகும், இது பயனருக்கு விடுதலை மற்றும் நெகிழ்வுத்தன்மையை வழங்குவதில் கவனம் செலுத்துகிறது. தனியுரிம திட்டங்கள் அல்லது வடிவங்கள் இல்லை, பக்கவாட்டு கோப்புகளின் கடல் இல்லை, உங்கள் கோப்பு முறைமை கட்டமைப்பின் முழுமையான எழுச்சி இல்லை.", + "about.description": "முகவரிச்சீட்டுஅறை என்பது ஒரு புகைப்படம் மற்றும் கோப்பு அமைப்பு பயன்பாடாகும், இது பயனருக்கு விடுதலை மற்றும் நெகிழ்வுத்தன்மையை வழங்குவதில் கவனம் செலுத்துகிறது. தனியுரிம திட்டங்கள் அல்லது வடிவங்கள் இல்லை, பக்கவாட்டு கோப்புகளின் கடல் இல்லை, உங்கள் கோப்பு முறைமை கட்டமைப்பின் முழுமையான எழுச்சி இல்லை.", "about.documentation": "ஆவணங்கள்", "about.license": "உரிமம்", "about.module.found": "காணப்பட்டது", - "about.title": "டேக்ச்டுடியோ பற்றி", + "about.title": "முகவரிச்சீட்டுஅறை பற்றி", "about.website": "வலைத்தளம்", "app.git": "அறிவிலி கமிட்", "app.pre_release": "முன் வெளியீடு", @@ -39,21 +39,32 @@ "entries.duplicate.merge": "நகல் உள்ளீடுகளை ஒன்றிணைக்கவும்", "entries.duplicate.merge.label": "நகல் உள்ளீடுகளை ஒன்றிணைத்தல் ...", "entries.duplicate.refresh": "நகல் உள்ளீடுகளைப் புதுப்பி", - "entries.duplicates.description": "மறுநுழைவுகள் என்பது, ஒரே கோப்பை குறிக்கும் பல நுழைவுகளை குறிக்கும். இவற்றை இணைப்பதால், அனைத்து மறுநுழைவுகளின் குறிச்சொற்களும் மெட்டாடேட்டாவும் ஒரே ஒட்டுமொத்த நுழைவாகச் சேர்க்கப்படும். இவற்றை 'மறுகோப்புகள்' என்பதுடன் குழப்பக் கூடாது, ஏனெனில் அவை டாக் ஸ்டுடியோவுக்கு வெளியேயுள்ள கோப்புகளின் நகல்களாகும்.", + "entries.duplicates.description": "மறுநுழைவுகள் என்பது, ஒரே கோப்பை குறிக்கும் பல நுழைவுகளை குறிக்கும். இவற்றை இணைப்பதால், அனைத்து மறுநுழைவுகளின் குறிச்சொற்களும் மெட்டாடேட்டாவும் ஒரே ஒட்டுமொத்த நுழைவாகச் சேர்க்கப்படும். இவற்றை 'மறுகோப்புகள்' என்பதுடன் குழப்பக் கூடாது, ஏனெனில் அவை முகவரிச்சீட்டுஅறைக்கு வெளியேயுள்ள கோப்புகளின் நகல்களாகும்.", + "entries.generic.refresh_alt": "&புதுப்பி", "entries.generic.remove.removing": "உள்ளீடுகள் நீக்கப்படுகிறது", + "entries.generic.remove.removing_count": "{count} உள்ளீடுகளை நீக்குகிறது...", + "entries.ignored.description": "பயனரின் புறக்கணிப்பு விதிகள் ('.ts_ignore' கோப்பு வழியாக) நீக்கப்படுவதற்கு முன், நூலகத்தில் சேர்க்கப்பட்டால், கோப்பு உள்ளீடுகள் \"புறக்கணிக்கப்பட்டதாக\" கருதப்படும். புறக்கணிக்கப்பட்ட கோப்புகள், புறக்கணிப்பு விதிகளைப் புதுப்பிக்கும் போது, தற்செயலான தரவு இழப்பைத் தடுக்க, இயல்புநிலையாக நூலகத்தில் வைக்கப்படும்.", + "entries.ignored.ignored_count": "புறக்கணிக்கப்பட்ட உள்ளீடுகள்: {count}", + "entries.ignored.remove": "புறக்கணிக்கப்பட்ட உள்ளீடுகளை அகற்று", + "entries.ignored.remove_alt": "புறக்கணிக்கப்பட்ட உள்ளீடுகளை அகற்று&விடு", + "entries.ignored.scanning": "புறக்கணிக்கப்பட்ட உள்ளீடுகளுக்காக நூலகத்தை வருடு செய்கிறது...", + "entries.ignored.title": "புறக்கணிக்கப்பட்ட உள்ளீடுகளை சரிசெய்யவும்", "entries.mirror": "& கண்ணாடி", "entries.mirror.confirmation": "பின்வரும் உள்ளீடுகளைப் பிரதிபலிக்க விரும்புகிறீர்களா {count}?", "entries.mirror.label": "{idx}/{total} உள்ளீடுகளைப் பிரதிபலிக்கப்படுகின்றது...", "entries.mirror.title": "உள்ளீடுகள் பிரதிபழிக்கப்படுகின்றது", "entries.mirror.window_title": "கண்ணாடி உள்ளீடுகள்", - "entries.remove.plural.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா {count}?", + "entries.remove.plural.confirm": "இந்த {count} உள்ளீடுகளை உங்கள் நூலகத்திலிருந்து நீக்க விரும்புகிறீர்களா? வட்டில் உள்ள எந்தக் கோப்புகளும் நீக்கப்படாது.", + "entries.remove.singular.confirm": "உங்கள் நூலகத்திலிருந்து இந்தப் பதிவை நிச்சயமாக அகற்ற விரும்புகிறீர்களா? வட்டில் உள்ள கோப்புகள் எதுவும் நீக்கப்படாது.", "entries.running.dialog.new_entries": "{total} புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது ...", "entries.running.dialog.title": "புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது", "entries.tags": "குறிச்சொற்கள்", - "entries.unlinked.description": "ஒவ்வொரு நூலக நுழைவும் உங்கள் கோப்பகங்களில் ஒன்றில் ஒரு கோப்போடு இணைக்கப்பட்டுள்ளது. ஒரு நுழைவுடன் இணைக்கப்பட்ட ஒரு கோப்பு டாக்ச்டுடியோவுக்கு வெளியே நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அது பின்னர் இணைக்கப்படாததாகக் கருதப்படுகிறது.", + "entries.unlinked.description": "ஒவ்வொரு நூலக நுழைவும் உங்கள் கோப்பகங்களில் ஒன்றில் ஒரு கோப்போடு இணைக்கப்பட்டுள்ளது. ஒரு நுழைவுடன் இணைக்கப்பட்ட ஒரு கோப்பு முகவரிச்சீட்டுஅறைக்கு வெளியே நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அது பின்னர் இணைக்கப்படாததாகக் கருதப்படுகிறது.", "entries.unlinked.relink.attempting": "{index}/{unlinked_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது", "entries.unlinked.relink.manual": "& கையேடு மறுபரிசீலனை", "entries.unlinked.relink.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது", + "entries.unlinked.remove": "இணைக்கப்படாத உள்ளீடுகளை அகற்று", + "entries.unlinked.remove_alt": "இணைக்கப்படாத உள்ளீடுகளை அகற்று&விடு", "entries.unlinked.scanning": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...", "entries.unlinked.search_and_relink": "& தேடல் && relink", "entries.unlinked.title": "இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்யவும்", @@ -67,8 +78,8 @@ "file.date_created": "உருவாக்கப்பட்ட தேதி", "file.date_modified": "மாற்றப்பட்ட தேதி", "file.dimensions": "பரிமாணங்கள்", - "file.duplicates.description": "நகல் கோப்புகளை நிர்வகிக்க டுபெகுரு முடிவுகளை இறக்குமதி செய்வதை டேக்ச்டுடியோ ஆதரிக்கிறது.", - "file.duplicates.dupeguru.advice": "படிமம் முடிந்தவுடன், தேவையற்ற கோப்புகளை நீக்க DupeGuru ஐ பயன்படுத்தலாம். அதற்குப் பிறகு, இணைக்காத நுழைவுகளை நீக்க 'டாக் ஸ்டுடியோ' வின் 'இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்' அம்சத்தைக் கருவிகள் பட்டியில் பயன்படுத்தவும்.", + "file.duplicates.description": "நகல் கோப்புகளை நிர்வகிக்க டுபெகுரு முடிவுகளை இறக்குமதி செய்வதை முகவரிச்சீட்டுஅறை ஆதரிக்கிறது.", + "file.duplicates.dupeguru.advice": "படிமம் முடிந்தவுடன், தேவையற்ற கோப்புகளை நீக்க DupeGuru ஐ பயன்படுத்தலாம். அதற்குப் பிறகு, இணைக்காத நுழைவுகளை நீக்க 'முகவரிச்சீட்டுஅறை' யின் 'இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்' அம்சத்தைக் கருவிகள் பட்டியில் பயன்படுத்தவும்.", "file.duplicates.dupeguru.file_extension": "DupeGuru கோப்புகள் (*.dupeguru)", "file.duplicates.dupeguru.load_file": "& டுபெகுரு கோப்பை ஏற்றவும்", "file.duplicates.dupeguru.no_file": "DupeGuru கோப்பு எதுவும் தேர்ந்தெடுக்கப்படவில்லை", @@ -107,47 +118,51 @@ "generic.edit": "திருத்து", "generic.edit_alt": "திருத்து (&e)", "generic.filename": "கோப்புப்பெயர்", - "generic.missing": "இல்லை", + "generic.missing": "காணவில்லை", "generic.navigation.back": "பின்", "generic.navigation.next": "அடுத்தது", + "generic.no": "இல்லை", "generic.none": "எதுவுமில்லை", "generic.overwrite": "மேலெழுதும்", "generic.overwrite_alt": "& மேலெழுதும்", "generic.paste": "ஒட்டு", "generic.recent_libraries": "சமீபத்திய நூலகங்கள்", + "generic.remove": "அகற்று", + "generic.remove_alt": "&நீக்கு", "generic.rename": "மறுபெயரிடுங்கள்", "generic.rename_alt": "& மறுபெயரிடுங்கள்", "generic.reset": "மீட்டமை", "generic.save": "சேமி", "generic.skip": "தவிர்", "generic.skip_alt": "& தவிர்க்கவும்", + "generic.yes": "ஆம்", "home.search": "தேடு", "home.search_entries": "தேடல் உள்ளீடுகள்", "home.search_library": "தேடல் நூலகம்", "home.search_tags": "குறிச்சொற்களைத் தேடு", + "home.show_hidden_entries": "மறைக்கப்பட்ட உள்ளீடுகளைக் காட்டு", "home.thumbnail_size": "சின்னப்பட அளவு", "home.thumbnail_size.extra_large": "கூடுதல் பெரிய சிறு உருவங்கள்", "home.thumbnail_size.large": "பெரிய சிறு உருவங்கள்", "home.thumbnail_size.medium": "நடுத்தர சிறு உருவங்கள்", "home.thumbnail_size.mini": "மினி சிறு உருவங்கள்", "home.thumbnail_size.small": "சிறிய சிறு உருவங்கள்", + "ignore.open_file": "வட்டில் கோப்பு \"{ts_ignore}\" எப்படி", "json_migration.checking_for_parity": "சமத்துவத்தை சரிபார்க்கிறது ...", "json_migration.creating_database_tables": "கவிமொ தரவுத்தள அட்டவணைகளை உருவாக்குதல் ...", - "json_migration.description": "
          நூலக இடம்பெயர்வு செயல்முறையின் முடிவுகளைத் தொடங்கவும் முன்னோட்டமிடவும். மாற்றப்பட்ட நூலகம் இல்லை நீங்கள் \"இடம்பெயர்வு முடிக்கவும்\" என்பதைக் சொடுக்கு செய்யாவிட்டால் பயன்படுத்தப்படும்.

          நூலகத் தரவுகள் பொருந்தக்கூடிய மதிப்புகளைக் கொண்டிருக்க வேண்டும் அல்லது \"பொருந்திய\" லேபிளைக் கொண்டிருக்க வேண்டும். பொருந்தாத மதிப்புகள் சிவப்பு நிறத்தில் காண்பிக்கப்படும் மற்றும் அவர்களுக்கு அடுத்த \" (!) \" சின்னத்தைக் கொண்டிருக்கும்.
          இந்தச் செயல்முறை பெரிய நூலகங்களுக்குப் பல நிமிடங்கள்வரை ஆகலாம்.
          ", + "json_migration.description": "
          நூலக இடம்பெயர்வு செயல்முறையின் முடிவுகளைத் தொடங்கவும் முன்னோட்டமிடவும். மாற்றப்பட்ட நூலகம் இல்லை நீங்கள் \"இடம்பெயர்வு முடிக்கவும்\" என்பதைக் சொடுக்கு செய்யாவிட்டால் பயன்படுத்தப்படும்.

          நூலகத் தரவுகள் பொருந்தக்கூடிய மதிப்புகளைக் கொண்டிருக்க வேண்டும் அல்லது \"பொருந்திய\" லேபிளைக் கொண்டிருக்க வேண்டும். பொருந்தாத மதிப்புகள் சிவப்பு நிறத்தில் காண்பிக்கப்படும் மற்றும் அவர்களுக்கு அடுத்த \" (!) \" சின்னத்தைக் கொண்டிருக்கும்.
          இந்தச் செயல்முறை பெரிய நூலகங்களுக்குப் பல நிமிடங்கள்வரை ஆகலாம்.
          ", "json_migration.discrepancies_found": "நூலக முரண்பாடுகள் காணப்படுகின்றன", "json_migration.discrepancies_found.description": "அசல் மற்றும் மாற்றப்பட்ட நூலக வடிவங்களுக்கு இடையில் முரண்பாடுகள் காணப்பட்டன. தயவுசெய்து மதிப்பாய்வு செய்து இடம்பெயர்வு தொடர வேண்டுமா அல்லது ரத்து செய்ய என்பதைத் தேர்வுசெய்க.", "json_migration.finish_migration": "இடம்பெயர்வு முடிக்கவும்", "json_migration.heading.aliases": "மாற்றுப்பெயர்கள்:", "json_migration.heading.colors": "நிறங்கள்:", "json_migration.heading.differ": "முரண்பாடு", - "json_migration.heading.extension_list_type": "நீட்டிப்பு பட்டியல் வகை:", - "json_migration.heading.file_extension_list": "கோப்பு நீட்டிப்பு பட்டியல்:", "json_migration.heading.match": "பொருந்தியது", "json_migration.heading.names": "பெயர்கள்:", "json_migration.heading.parent_tags": "பெற்றோர் குறிச்சொற்கள்:", "json_migration.heading.paths": "பாதைகள்:", "json_migration.heading.shorthands": "சுருக்கெழுத்து:", - "json_migration.info.description": "டேக்ச்டுடியோ பதிப்புகளுடன் உருவாக்கப்பட்ட கோப்புகளை நூலகம் சேமிக்கவும் 9.4 மற்றும் கீழே புதிய v9.5+ வடிவத்திற்கு இடம்பெயர வேண்டும். இல்லை நீக்கப்பட வேண்டும், நகர்த்தப்படும் அல்லது மாற்றியமைக்கப்பட வேண்டும்
        • புதிய V9.5+ சேமிக்கும் வடிவமைப்பை டேக்ச்டுடியோவின் முந்தைய பதிப்புகளில் திறக்க முடியாது

        என்ன மாற்றப்பட்டுள்ளது:

        • \"குறிச்சொற்கள்\" குறிச்சொற்களால் மாற்றப்பட்டுள்ளன. முதலில் புலங்களில் குறிச்சொற்களைச் சேர்ப்பதற்கு பதிலாக, குறிச்சொற்கள் இப்போது கோப்பு உள்ளீடுகளில் நேரடியாக சேர்க்கப்படுகின்றன. குறிச்சொல் திருத்துதல் பட்டியலில் புதிய \"வகை\" சொத்துடன் குறிக்கப்பட்ட பெற்றோர் குறிச்சொற்களின் அடிப்படையில் அவை தானாகவே வகைகளாக ஒழுங்கமைக்கப்படுகின்றன. எந்தவொரு குறிச்சொல்லையும் ஒரு வகையாகக் குறிக்க முடியும், மேலும் குழந்தை குறிச்சொற்கள் வகைகளாக குறிக்கப்பட்ட பெற்றோர் குறிச்சொற்களுக்கு அடியில் தங்களை வரிசைப்படுத்தும். \"பிடித்த\" மற்றும் \"காப்பகப்படுத்தப்பட்ட\" குறிச்சொற்கள் இப்போது ஒரு புதிய \"மேவு குறிச்சொற்கள்\" குறிச்சொல்லிலிருந்து பெறப்படுகின்றன, இது இயல்புநிலையாக ஒரு வகையாக குறிக்கப்பட்டுள்ளது.
        • குறிச்சொல் வண்ணங்கள் மாற்றப்பட்டு விரிவாக்கப்பட்டுள்ளன. சில வண்ணங்கள் மறுபெயரிடப்பட்டுள்ளன அல்லது ஒருங்கிணைக்கப்பட்டுள்ளன, இருப்பினும் எல்லா குறிச்சொல் வண்ணங்களும் V9.5 இல் உள்ள சரியான அல்லது நெருக்கமான போட்டிகளாக மாறும்.
          ", + "json_migration.info.description": "முகவரிச்சீட்டுஅறை பதிப்புகளுடன் உருவாக்கப்பட்ட கோப்புகளை நூலகம் சேமி 9.4 மற்றும் கீழே புதிய v9.5+ வடிவத்திற்கு இடம்பெயர வேண்டும். இல்லை நீக்கப்பட வேண்டும், நகர்த்தப்படும் அல்லது மாற்றியமைக்கப்பட வேண்டும்
        • புதிய V9.5+ சேமிக்கும் வடிவமைப்பை முகவரிச்சீட்டுஅறையின் முந்தைய பதிப்புகளில் திறக்க முடியாது

        என்ன மாற்றப்பட்டுள்ளது:

        • \"குறிச்சொற்கள்\" குறிச்சொற்களால் மாற்றப்பட்டுள்ளன. முதலில் புலங்களில் குறிச்சொற்களைச் சேர்ப்பதற்கு பதிலாக, குறிச்சொற்கள் இப்போது கோப்பு உள்ளீடுகளில் நேரடியாகச் சேர்க்கப்படுகின்றன. குறிச்சொல் திருத்துதல் பட்டியலில் புதிய \"வகை\" சொத்துடன் குறிக்கப்பட்ட பெற்றோர் குறிச்சொற்களின் அடிப்படையில் அவை தானாகவே வகைகளாக ஒழுங்கமைக்கப்படுகின்றன. எந்தவொரு குறிச்சொல்லையும் ஒரு வகையாகக் குறிக்க முடியும், மேலும் குழந்தை குறிச்சொற்கள் வகைகளாகக் குறிக்கப்பட்ட பெற்றோர் குறிச்சொற்களுக்கு அடியில் தங்களை வரிசைப்படுத்தும். \"பிடித்த\" மற்றும் \"காப்பகப்படுத்தப்பட்ட\" குறிச்சொற்கள் இப்போது ஒரு புதிய \"மேவு குறிச்சொற்கள்\" குறிச்சொல்லிலிருந்து பெறப்படுகின்றன, இது இயல்புநிலையாக ஒரு வகையாகக் குறிக்கப்பட்டுள்ளது.
        • குறிச்சொல் வண்ணங்கள் மாற்றப்பட்டு விரிவாக்கப்பட்டுள்ளன. சில வண்ணங்கள் மறுபெயரிடப்பட்டுள்ளன அல்லது ஒருங்கிணைக்கப்பட்டுள்ளன, இருப்பினும் எல்லா குறிச்சொல் வண்ணங்களும் V9.5 இல் உள்ள சரியான அல்லது நெருக்கமான போட்டிகளாக மாறும்.
          ", "json_migration.migrating_files_entries": "இடம்பெயர்வு {entries:,d} கோப்பு உள்ளீடுகள் ...", "json_migration.migration_complete": "இடம்பெயர்வு முடிந்தது!", "json_migration.migration_complete_with_discrepancies": "இடம்பெயர்வு முடிந்தது, முரண்பாடுகள் காணப்படுகின்றன", @@ -167,9 +182,21 @@ "library.refresh.scanning_preparing": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\nதயாராகிறது...", "library.refresh.title": "கோப்பகங்கள் புதுப்பிக்கப்படுகின்றன", "library.scan_library.title": "புத்தககல்லரி சோதனை செய்யப்படுகிறது", + "library_info.cleanup": "தூய்மை", + "library_info.cleanup.backups": "நூலக காப்புப்பிரதிகள்:", + "library_info.cleanup.dupe_files": "நகல் கோப்புகள்:", + "library_info.cleanup.ignored": "புறக்கணிக்கப்பட்ட உள்ளீடுகள்:", + "library_info.cleanup.legacy_json": "எஞ்சியிருக்கும் மரபு நூலகம்:", + "library_info.cleanup.unlinked": "இணைக்கப்படாத உள்ளீடுகள்:", + "library_info.stats": "புள்ளிவிவரங்கள்", + "library_info.stats.colors": "குறிச்சொல் நிறங்கள்:", "library_info.stats.entries": "உள்ளீடுகள்:", "library_info.stats.fields": "புலங்கள்:", + "library_info.stats.macros": "மேக்ரோக்கள்:", + "library_info.stats.namespaces": "பெயர்வெளிகள்:", "library_info.stats.tags": "குறிச்சொற்கள்:", + "library_info.title": "நூலகம் '{library_dir}'", + "library_info.version": "நூலக வடிவமைப்பு பதிப்பு: {version}", "library_object.name": "பெயர்", "library_object.name_required": "பெயர் (தேவை)", "library_object.slug": "ஐடி ச்லக்", @@ -187,15 +214,16 @@ "menu.edit.new_tag": "புதிய & குறிச்சொல்", "menu.file": "கோப்பு (&f)", "menu.file.clear_recent_libraries": "சமீபத்தியதை அழிக்கவும்", - "menu.file.close_library": "& நூலகம் மூடு", + "menu.file.close_library": " நூலகம் மூடு", "menu.file.missing_library.message": "\"{library}\" நூலகத்தின் இருப்பிடத்தைக் கண்டுபிடிக்க முடியாது.", "menu.file.missing_library.title": "நூலகம் இல்லை", "menu.file.new_library": "புதிய நூலகம்", + "menu.file.open_backups_folder": "காப்புப்பிரதிகள் கோப்புறையைத் திறக்கவும்", "menu.file.open_create_library": "& நூலகத்தைத் திறக்க/உருவாக்கவும்", "menu.file.open_library": "திறந்த நூலகம்", "menu.file.open_recent_library": "அண்மைக் கால திறப்பு", "menu.file.refresh_directories": "கோப்பகத்தை புதுப்பிக்கவும்", - "menu.file.save_backup": "& நூலக காப்புப்பிரதியை சேமிக்கவும்", + "menu.file.save_backup": " நூலக காப்புப்பிரதியை சேமிக்கவும்", "menu.file.save_library": "நூலகத்தை சேமிக்கவும்", "menu.help": "உதவி (&h)", "menu.help.about": "பற்றி", @@ -204,17 +232,23 @@ "menu.select": "தேர்ந்தெடு", "menu.settings": "அமைப்புகள் ...", "menu.tools": "கருவிகள் (&t)", - "menu.tools.fix_duplicate_files": "நகல் & கோப்புகளை சரிசெய்யவும்", + "menu.tools.fix_duplicate_files": "& நகல் கோப்புகளை சரிசெய்யவும்", + "menu.tools.fix_ignored_entries": "&புறக்கணிக்கப்பட்ட உள்ளீடுகளைச் சரிசெய்யவும்", "menu.tools.fix_unlinked_entries": "சரிசெய்யப்படாத உள்ளீடுகளை சரிசெய்யவும்", "menu.view": "காண்க (&v)", + "menu.view.decrease_thumbnail_size": "சிறுபடத்தின் அளவைக் குறைக்கவும்", + "menu.view.increase_thumbnail_size": "சிறுபடத்தின் அளவை அதிகரிக்கவும்", + "menu.view.library_info": "நூலகம் &தகவல்", "menu.window": "சாளரம்", - "namespace.create.description": "குறிச்சொற்கள் மற்றும் வண்ணங்கள் போன்ற பொருட்களின் குழுக்களை ஏற்றுமதி செய்வதற்கும் பகிர்வதற்கும் எளிதாக்கும் வகையில் பிரிக்கப்படுவதற்கு பெயர்வெளிகள் டேக்ச்டுடியோவால் பயன்படுத்தப்படுகின்றன. \"டேக்ச்டுடியோ\" உடன் தொடங்கும் பெயர்வெளிகள் உள் பயன்பாட்டிற்காக டேக்ச்டுடியோவால் ஒதுக்கப்பட்டுள்ளன.", + "namespace.create.description": "குறிச்சொற்கள் மற்றும் வண்ணங்கள் போன்ற பொருட்களின் குழுக்களை ஏற்றுமதி செய்வதற்கும் பகிர்வதற்கும் எளிதாக்கும் வகையில் பிரிக்கப்படுவதற்கு பெயர்வெளிகள் முகவரிச்சீட்டுஅறையால் பயன்படுத்தப்படுகின்றன. \"முகவரிச்சீட்டுஅறை\" உடன் தொடங்கும் பெயர்வெளிகள் உள் பயன்பாட்டிற்காக முகவரிச்சீட்டுஅறையால் ஒதுக்கப்பட்டுள்ளன.", "namespace.create.description_color": "குறிச்சொல் வண்ணங்கள் பெயர்வெளிகளை வண்ணத் தட்டு குழுக்களாகப் பயன்படுத்துகின்றன. அனைத்து தனிப்பயன் வண்ணங்களும் முதலில் ஒரு பெயர்வெளி குழுவின் கீழ் இருக்க வேண்டும்.", "namespace.create.title": "பெயர்வெளியை உருவாக்கவும்", "namespace.new.button": "புதிய பெயர்வெளி", "namespace.new.prompt": "தனிப்பயன் வண்ணங்களைச் சேர்க்கத் தொடங்க புதிய பெயர்வெளியை உருவாக்கவும்!", + "preview.ignored": "புறக்கணிக்கப்பட்டது", "preview.multiple_selection": " {count} தேர்ந்தெடுக்கப்பட்ட உருப்படிகள்", "preview.no_selection": "உருப்படிகள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை", + "preview.unlinked": "இணைக்கப்படவில்லை", "select.add_tag_to_selected": "தேர்ந்தெடுக்கப்பட்டவருக்கு குறிச்சொல்லைச் சேர்க்கவும்", "select.all": "அனைத்தையும் தெரிவுசெய்", "select.clear": "தெளிவான தேர்வு", @@ -228,15 +262,23 @@ "settings.filepath.option.full": "முழு பாதைகளையும் காட்டு", "settings.filepath.option.name": "கோப்பு பெயர்களைக் காட்டு", "settings.filepath.option.relative": "உறவினர் பாதைகளைக் காட்டு", + "settings.generate_thumbs": "சிறுபட உருவாக்கம்", "settings.global": "உலகளாவிய அமைப்புகள்", "settings.hourformat.label": "24 மணி நேர நேரம்", + "settings.infinite_scroll": "எல்லையற்ற ச்க்ரோலிங்", "settings.language": "மொழி", "settings.library": "நூலக அமைப்புகள்", "settings.open_library_on_start": "தொடக்கத்தில் நூலகத்தைத் திறக்கவும்", "settings.page_size": "பக்க அளவு", - "settings.restart_required": "மாற்றங்கள் நடைமுறைக்கு வருவதற்கு டேக்ச்டுடியோவை மறுதொடக்கம் செய்யுங்கள்.", + "settings.restart_required": "மாற்றங்கள் நடைமுறைக்கு வருவதற்கு முகவரிச்சீட்டுஅறையை மறுதொடக்கம் செய்.", "settings.show_filenames_in_grid": "கட்டத்தில் கோப்பு பெயர்களைக் காட்டு", "settings.show_recent_libraries": "அண்மைக் கால நூலகங்களைக் காட்டு", + "settings.splash.label": "ச்பிளாச் திரை", + "settings.splash.option.classic": "கிளாசிக் (9.0)", + "settings.splash.option.default": "இயல்புநிலை", + "settings.splash.option.goo_gears": "திறந்த மூல (9.4)", + "settings.splash.option.ninety_five": "'95 (9.5)", + "settings.splash.option.random": "சீரற்ற", "settings.tag_click_action.add_to_search": "தேடுவதற்கு குறிச்சொல்லைச் சேர்க்கவும்", "settings.tag_click_action.label": "குறிச்சொல் செயலை சொடுக்கு செய்க", "settings.tag_click_action.open_edit": "குறிச்சொல்லைத் திருத்து", @@ -245,10 +287,12 @@ "settings.theme.label": "தீம்:", "settings.theme.light": "ஒளி", "settings.theme.system": "மண்டலம்", + "settings.thumb_cache_size.label": "சிறுபடம் தற்காலிக சேமிப்பு அளவு", "settings.title": "அமைப்புகள்", "settings.zeropadding.label": "தேதி பூச்சிய-பேடிங்", "sorting.direction.ascending": "ஏறுதல்", "sorting.direction.descending": "இறங்கு", + "sorting.mode.random": "சீரற்ற", "splash.opening_library": "\"{library_path}\" ஐ திறக்கும் ...", "status.deleted_file_plural": "நீக்கப்பட்டது {count} கோப்புகள்!", "status.deleted_file_singular": "1 கோப்பு நீக்கப்பட்டது!", @@ -280,6 +324,7 @@ "tag.disambiguation.tooltip": "இந்த குறிச்சொல்லைப் பயன்படுத்தவும்", "tag.edit": "குறிச்சொல்லைத் திருத்து", "tag.is_category": "வகை", + "tag.is_hidden": "மறைக்கப்பட்டுள்ளது", "tag.name": "பெயர்", "tag.new": "புதிய குறிச்சொல்", "tag.parent_tags": "பெற்றோர் குறிச்சொற்கள்", @@ -294,8 +339,8 @@ "trash.context.ambiguous": "கோப்புகளை நகர்த்தவும்) {trash_term}", "trash.context.plural": "கோப்புகளை {trash_term} பெறுநர் க்கு நகர்த்தவும்", "trash.context.singular": "கோப்பை {trash_term} பெறுநர் க்கு நகர்த்தவும்", - "trash.dialog.disambiguation_warning.plural": "இது அவற்றை டேக்ச்டுடியோ மற்றும் உங்கள் கோப்பு முறைமையிலிருந்து அகற்றும்!", - "trash.dialog.disambiguation_warning.singular": "இது டேக்ச்டுடியோ மற்றும் உங்கள் கோப்பு முறைமையிலிருந்து அகற்றப்படும்!", + "trash.dialog.disambiguation_warning.plural": "இது அவற்றை முகவரிச்சீட்டுஅறை மற்றும் உங்கள் கோப்பு முறைமையிலிருந்து அகற்றும்!", + "trash.dialog.disambiguation_warning.singular": "இது முகவரிச்சீட்டுஅறை மற்றும் உங்கள் கோப்பு முறைமையிலிருந்து அகற்றப்படும்!", "trash.dialog.move.confirmation.plural": "இந்த {count} கோப்புகளை {trash_term} க்கு நகர்த்த விரும்புகிறீர்களா?", "trash.dialog.move.confirmation.singular": "இந்த கோப்பை {trash_term} with க்கு நகர்த்த விரும்புகிறீர்களா?", "trash.dialog.permanent_delete_warning": " எச்சரிக்கை! இந்தக் கோப்பை {trash_term} க்கு மாற்ற முடியாவிட்டால், இது நிரந்தரமாக நீக்கப்படும்! ", @@ -303,6 +348,9 @@ "trash.dialog.title.singular": "கோப்பை அழி", "trash.name.generic": "குப்பை", "trash.name.windows": "மறுசுழற்சி பின்", + "version_modal.description": "முகவரிச்சீட்டுஅறை இன் புதிய பதிப்பு கிடைக்கிறது! சமீபத்திய வெளியீட்டை நீங்கள் பதிவிறக்கம் செய்யலாம் அறிவிலிமையம்.", + "version_modal.status": "நிறுவப்பட்ட பதிப்பு: {installed_version}
          அண்மைகால வெளியீட்டு பதிப்பு: {latest_release_version}", + "version_modal.title": "முகவரிச்சீட்டுஅறை புதுப்பிப்பு கிடைக்கிறது", "view.size.0": "மினி", "view.size.1": "சிறிய", "view.size.2": "சராசரி", diff --git a/src/tagstudio/resources/translations/th.json b/src/tagstudio/resources/translations/th.json new file mode 100644 index 000000000..351fccc10 --- /dev/null +++ b/src/tagstudio/resources/translations/th.json @@ -0,0 +1,8 @@ +{ + "about.config_path": "เส้นทางกำหนดค่า", + "about.description": "TagStudio เป็นแอปพลิเคชันจัดระเบียบรูปภาพและไฟล์ที่มีระบบพื้นฐานแบบแท็ก ซึ่งเน้นความยืดหยุ่นให้แก่ผู้ใช้ ไม่มีโปรแกรมหรือรูปแบบเฉพาะ ไม่มีไฟล์เสริมจำนวนมาก และไม่มีการเปลี่ยนแปลงโครงสร้างระบบไฟล์ของคุณอย่างสิ้นเชิง", + "about.documentation": "เอกสารประกอบ", + "about.license": "ใบอนุญาต", + "about.module.found": "พบ", + "about.title": "เกี่ยวกับ TagStudio" +} diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index 05b4b46ad..cce1fe9dd 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -1,25 +1,25 @@ { "about.config_path": "nasin pi ante lawa", - "about.description": "ilo Tagstudio li ilo pi lawa lipu li ilo pi lawa sitelen li kepeken nasin pi poki pona. ilo Tagstudio li wile pana e lawa mute tawa jan kepeken. nasin pi open ala li lon ala, en ma pi lipu poka li lon ala, en sina li ante ala e nasin lipu ale sina.", + "about.description": "ilo Tagstudio li ilo pi lawa lipu li ilo pi lawa sitelen li kepeken nasin pi poki pona. ilo Tagstudio li wile pana e lawa mute tawa jan kepeken. nasin taso pi kulupu jan li lon, ma pi lipu poka li lon ala, sina li ante ala e nasin lipu ale sina.", "about.documentation": "lipu sona", - "about.license": "lipu lawa", + "about.license": "lipu pi ken sina", "about.module.found": "lukin", - "about.title": "sona pi ilo Tagstudio", + "about.title": "sona pi ilo TagStudio", "about.website": "lipu linluwi", "app.git": "Git Commit", - "app.pre_release": "nanpa pi pakala mute", + "app.pre_release": "nanpa pi pakala ken", "app.title": "{base_title} - tomo '{library_dir}'", "color.color_border": "o kepeken kule nanpa tu lon selo", "color.confirm_delete": "sina wile ala wile weka e kule \"{color_name}\"?", "color.delete": "o weka e poki", "color.import_pack": "o kama jo e kulupu kule", "color.name": "nimi", - "color.namespace.delete.prompt": "sina wile ala wile weka e ma nimi kule ni? weka la kule ale lon ma ni li weka kin!", - "color.namespace.delete.title": "o weka e ma nimi kule", + "color.namespace.delete.prompt": "sina wile ala wile weka e kulupu kule ni? weka la kule ale lon ona li weka kin!", + "color.namespace.delete.title": "o weka e kulupu kule", "color.new": "kule sin", "color.placeholder": "kule", "color.primary": "kule nanpa wan", - "color.primary_required": "kule nanpa wan (wile mute)", + "color.primary_required": "kule nanpa wan (ni o lon)", "color.secondary": "kule nanpa tu", "color.title.no_color": "kule ala", "color_manager.title": "o lawa e kule poki", @@ -28,9 +28,9 @@ "drop_import.duplicates_choice.plural": "lipu {count} ni li jo e nasin lipu sama lon tomo.", "drop_import.duplicates_choice.singular": "lipu ni li jo e nasin lipu sama lon tomo.", "drop_import.progress.label.initial": "mi kama jo e lipu sin...", - "drop_import.progress.label.plural": "mi kama jo e lipu sin...\nmi kama jo e lipu {count}.{suffix}", - "drop_import.progress.label.singular": "mi kama jo e lipu sin...\nmi kama jo e lipu wan.{suffix}", - "drop_import.progress.window_title": "o kama jo e lipu", + "drop_import.progress.label.plural": "mi kama jo e lipu sin...\nmi jo e lipu sin {count}.{suffix}", + "drop_import.progress.label.singular": "mi kama jo e lipu sin...\nmi jo e lipu sin 1.{suffix}", + "drop_import.progress.window_title": "o kama jo e lipu sin", "drop_import.title": "lipu ike", "edit.color_manager": "o lawa e kule poki", "edit.copy_fields": "o kama jo e ma sama", @@ -38,15 +38,23 @@ "edit.tag_manager": "o lawa e poki", "entries.duplicate.merge": "o wan e ijo sama", "entries.duplicate.merge.label": "mi wan e ijo sama...", - "entries.duplicate.refresh": "o kama jo e sona tan ijo sama", - "entries.duplicates.description": "ken la, ijo mute li jo e ijo lon sama. ni li \"ijo sama\". sina wan e ona la, ijo sama li kama wan li jo e sona ale tan ijo sama ale.", + "entries.duplicate.refresh": "o sin sin e ijo sama", + "entries.duplicates.description": "ken la, ijo mute li jo e ijo lon sama. ni li \"ijo sama\". sina wan e ona la, ijo sama li kama wan li jo e sona ale tan ijo sama ale. o sona e ni: \"ijo sama\" li \"lipu sama\" ala.", "entries.generic.remove.removing": "mi weka e ijo", + "entries.generic.remove.removing_count": "mi weka e ijo {count}...", + "entries.ignored.description": "sina pana e ijo lipu lon tomo, la sina weka e ona lon lawa toki pi lukin ala la, ona li \"lukin ala\". meso la, lipu pi lukin ala li lon tomo tan ni: ni ala la sina ante e lawa toki pi lukin ala la, nanpa li ken pakala.", + "entries.ignored.ignored_count": "ijo pi lukin ala: {count}", + "entries.ignored.remove": "o weka e ijo pi lukin ala", + "entries.ignored.remove_alt": "o weka e ijo pi lukin ala (&V)", + "entries.ignored.scanning": "mi alasa e ijo pi lukin ala...", + "entries.ignored.title": "o pona e ijo pi lukin ala", "entries.mirror": "jasi&ma", "entries.mirror.confirmation": "mi jasima e ijo {count}. ni li pona anu seme?", "entries.mirror.label": "mi jasima e ijo {idx}/{total}...", "entries.mirror.title": "mi jasima e ijo", "entries.mirror.window_title": "o jasima e ijo", "entries.remove.plural.confirm": "mi weka e ijo {count}. ni li pona anu seme? poki lipu pi ilo sina la lipu ala li weka.", + "entries.remove.singular.confirm": "mi weka e ijo ni. ni li pona anu seme? poki lipu pi ilo sina la lipu ala li weka.", "entries.running.dialog.new_entries": "mi pana e lipu sin {total}...", "entries.running.dialog.title": "mi pana e lipu sin", "entries.tags": "poki", @@ -54,6 +62,8 @@ "entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo {index}/{unlinked_count}. mi pana e ijo lon tawa ijo {fixed_count}", "entries.unlinked.relink.manual": "sina o pana e ijo lon tawa ijo (&M)", "entries.unlinked.relink.title": "mi pana e ijo lon tawa ijo", + "entries.unlinked.remove": "o weka e ijo pi ijo lon ala", + "entries.unlinked.remove_alt": "o weka e ijo pi ijo lon ala (&V)", "entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...", "entries.unlinked.search_and_relink": "o ala&sa o pana e ijo lon tawa ijo", "entries.unlinked.title": "o pona e ijo pi ijo lon ala", @@ -71,7 +81,7 @@ "file.duplicates.dupeguru.advice": "jasima li pini la, sina ken kepeken ilo DupeGuru. ilo DupeGuru li ken weka e ijo ike. ni li pini la, o kepeken nasin \"o pona e ijo pi ijo lon ala\" lon ilo TagStudio. ni li weka e ijo pi ijo lon ala.", "file.duplicates.dupeguru.file_extension": "ijo DupeGuru (*.dupeguru)", "file.duplicates.dupeguru.load_file": "o kama &lon e ijo DupeGuru", - "file.duplicates.dupeguru.no_file": "sina o anu e ijo DupeGuru", + "file.duplicates.dupeguru.no_file": "sina jo ala e lipu DupeGuru", "file.duplicates.dupeguru.open_file": "o open e sona pini tan ilo DupeGuru", "file.duplicates.fix": "pona e ijo sama", "file.duplicates.matches": "ijo sama: %{count}", @@ -120,26 +130,30 @@ "generic.rename_alt": "o nimi sin (&R)", "generic.reset": "o open sin", "generic.save": "o awen", + "generic.skip": "o pali ala", + "generic.skip_alt": "o pali ala (&S)", "generic.yes": "lon", "home.search": "o alasa", "home.search_entries": "o alasa lon ijo", "home.search_library": "o alasa lon tomo", "home.search_tags": "o alasa lon poki", + "home.show_hidden_entries": "o ken lukin e lipu pi ken ala lukin", "home.thumbnail_size": "suli sitelen", "home.thumbnail_size.extra_large": "sitelen pi suli mute", "home.thumbnail_size.large": "sitelen suli", "home.thumbnail_size.medium": "sitelen meso", "home.thumbnail_size.mini": "sitelen pi lili mute", "home.thumbnail_size.small": "sitelen lili", + "ignore.open_file": "o ken lukin e lipu \"{ts_ignore}\" lon ilo sina", "json_migration.checking_for_parity": "mi alasa e nasin tu...", + "json_migration.creating_database_tables": "mi pali e poki SQL mute...", "json_migration.description": "
          o open e tawa tomo o lukin e pini. sina pilin ala e \"o pini e tawa\" la, mi kepeken ala e tomo ante.

          sona tomo o jo e nanpa sama anu toki \"sama\" la ale li pona. nanpa ante li loje li jo e sitelen \"(!)\" lon poka ona.
          tomo li suli la pali ni li lanpan e tenpo mute.
          ", "json_migration.discrepancies_found": "mi lukin e ike pi tomo sina", "json_migration.discrepancies_found.description": "mi lukin e ike lon nasin tomo open lon nasin tomo ante. o lukin, o awen tawa anu ala.", "json_migration.finish_migration": "o pini e tawa", "json_migration.heading.aliases": "nimi ante:", "json_migration.heading.colors": "kule:", - "json_migration.heading.differ": "ike", - "json_migration.heading.file_extension_list": "kulupu pi namako lipu:", + "json_migration.heading.differ": "ante ike", "json_migration.heading.match": "sama", "json_migration.heading.names": "nimi:", "json_migration.heading.parent_tags": "poki mama:", @@ -165,18 +179,25 @@ "library.refresh.scanning_preparing": "mi alasa e ijo sin lon tomo...\nmi kama pona...", "library.refresh.title": "mi kama jo e sin lon tomo", "library.scan_library.title": "mi o lukin e tomo", + "library_info.cleanup": "jaki", + "library_info.cleanup.backups": "sama awen tomo:", + "library_info.cleanup.dupe_files": "lipu ni li sama:", + "library_info.cleanup.ignored": "ijo pi lukin ala:", "library_info.stats": "sona nanpa", "library_info.stats.colors": "kule poki:", "library_info.stats.entries": "ijo:", "library_info.stats.fields": "ma:", + "library_info.stats.macros": "ilo pali:", "library_info.stats.namespaces": "ma nimi:", "library_info.stats.tags": "poki:", + "library_info.title": "tomo '{library_dir}'", + "library_info.version": "nanpa pi nasin tomo: {version}", "library_object.name": "nimi", "library_object.name_required": "nimi (wile mute)", "library_object.slug": "ID Slug", "library_object.slug_required": "ID Slug (wile mute)", - "macros.running.dialog.new_entries": "mi pali lon ijo sin {count}/{total}...", - "macros.running.dialog.title": "mi pali lon ijo sin", + "macros.running.dialog.new_entries": "mi ilo pali lon ijo sin {count}/{total}...", + "macros.running.dialog.title": "mi ilo pali lon ijo sin", "media_player.autoplay": "pali pi sina ala", "media_player.loop": "pali sike", "menu.delete_selected_files_ambiguous": "o tawa e lipu tawa {trash_term}", @@ -192,10 +213,12 @@ "menu.file.missing_library.message": "mi ken ala lukin e lon pi tomo \"{library}\".", "menu.file.missing_library.title": "tomo pi lon ala", "menu.file.new_library": "o sin e tomo", + "menu.file.open_backups_folder": "o open e kulupu pi sama awen", "menu.file.open_create_library": "o &open/pali e tomo", "menu.file.open_library": "o open e tomo", "menu.file.open_recent_library": "o open e poka", "menu.file.refresh_directories": "o lukin sin lon tomo (&R)", + "menu.file.save_backup": "o awen e &sama awen tomo", "menu.file.save_library": "o awen e sona tomo", "menu.help": "mi jo e toki seme (&H)", "menu.help.about": "sona", @@ -205,23 +228,26 @@ "menu.settings": "lawa toki...", "menu.tools": "ilo (&T)", "menu.tools.fix_duplicate_files": "o pona e lipu sama (&D)", + "menu.tools.fix_ignored_entries": "o pona e &ijo pi lukin ala", "menu.tools.fix_unlinked_entries": "o pona e ijo pi ijo lon ala (&U)", "menu.view": "o lukin (&V)", "menu.view.decrease_thumbnail_size": "o lili e sitelen", "menu.view.increase_thumbnail_size": "o suli e sitelen", "menu.view.library_info": "sona pi tomo n&i", "menu.window": "lipu", - "namespace.create.description": "ilo TagStuidio li kulupu e poki e kule kepeken nasin pi pana pona la ilo li kepeken ma nimi. ma nimi pi nimi open \"tagstudio\" li ilo TagStudio taso. ilo TagStudio li kepeken ona lon insa.", + "namespace.create.description": "ilo TagStudio li kulupu e poki e kule kepeken nasin pi pana pona la ilo li kepeken ma nimi. ma nimi pi nimi open \"tagstudio\" li ilo TagStudio taso. ilo TagStudio li kepeken ona lon insa.", "namespace.create.description_color": "kule poki li kepeken ma nimi sama kulupu pi kulupu kule. ale la open la kule ale pi pali sina li lon kulupu pi ma nimi.", "namespace.create.title": "o pali sin e ma nimi", "namespace.new.button": "o pali sin e ma nimi", "namespace.new.prompt": "o pali sin e ma nimi tawa pana e kule sina!", + "preview.ignored": "lukin ala", "preview.multiple_selection": "sina jo e ijo {count}", - "preview.no_selection": "ijo ala li anu", + "preview.no_selection": "sina jo e ijo ala", "select.add_tag_to_selected": "o pana e poki tawa jo sina", "select.all": "o jo e ale", "select.clear": "o weka e jo sina", - "select.inverse": "o jasima e ni", + "select.inverse": "o jasima e jo sina", + "settings.clear_thumb_cache.title": "o weka e poki sitelen", "settings.dateformat.english": "nasin Inli", "settings.dateformat.international": "nasin pi ma mute", "settings.dateformat.label": "nasin tenpo", @@ -229,30 +255,38 @@ "settings.filepath.label": "ken lukin pi nasin lipu", "settings.filepath.option.full": "o ken lukin e nasin wan", "settings.filepath.option.name": "o ken lukin e nimi lipu taso", + "settings.generate_thumbs": "pali pi sitelen", "settings.global": "lawa toki pi ma ale", "settings.hourformat.label": "tenpo pi kipisi 24", "settings.language": "toki", "settings.library": "lawa toki pi tomo mi", - "settings.open_library_on_start": "ilo Tagstudio li open la o open e tomo ni", + "settings.open_library_on_start": "ilo TagStudio li open la o open e tomo", "settings.page_size": "suli lipu", - "settings.restart_required": "sina open sin e ilo Tagstudio la ante li lon.", + "settings.restart_required": "sina open sin e ilo TagStudio la ante li lon.", "settings.show_filenames_in_grid": "o sitelen e nimi ijo lon leko sitelen", "settings.show_recent_libraries": "o sitelen e tomo pi tenpo poka", "settings.splash.label": "sitelen open", + "settings.splash.option.random": "nasa", "settings.tag_click_action.add_to_search": "o pana e poki tawa alasa", + "settings.tag_click_action.label": "pali pi pilin poki", "settings.tag_click_action.open_edit": "o ante e poki", "settings.tag_click_action.set_search": "o alasa e poki", "settings.theme.dark": "pimeja", "settings.theme.label": "nasin kule:", "settings.theme.light": "walo", "settings.theme.system": "ilo sina", + "settings.thumb_cache_size.label": "suli pi poki sitelen", "settings.title": "lawa toki", + "sorting.direction.ascending": "tawa sewi", + "sorting.direction.descending": "tawa anpa", + "sorting.mode.random": "nasa", "splash.opening_library": "mi open e tomo \"{library_path}\"...", "status.deleted_file_plural": "mi weka e lipu {count}!", "status.deleted_file_singular": "mi weka e lipu 1!", "status.deleted_none": "mi weka e lipu ala.", "status.deleted_partial_warning": "mi weka e lipu {count} taso! o lukin tan ni: lipu li weka anu ijo li kepeken ona anu seme.", "status.deleting_file": "mi weka e lipu [{i}/{count}]: \"{path}\"...", + "status.library_backup_in_progress": "mi awen e sama awen tomo...", "status.library_backup_success": "tomo sama li lon: \"{path}\" ({time_span})", "status.library_closed": "tomo li pini ({time_span})", "status.library_closing": "mi pini e tomo...", @@ -274,8 +308,10 @@ "tag.confirm_delete": "sina wile ala wile weka e poki \"{tag_name}\"?", "tag.create": "o pali sin e poki", "tag.create_add": "o pali sin && o pana e \"{query}\"", + "tag.disambiguation.tooltip": "o kepeken poki ni lon nimi", "tag.edit": "o ante e poki", "tag.is_category": "poki ala poki", + "tag.is_hidden": "ken ala lukin", "tag.name": "nimi", "tag.new": "poki sin", "tag.parent_tags": "poki mama", @@ -290,8 +326,8 @@ "trash.context.ambiguous": "o tawa e lipu tawa {trash_term}", "trash.context.plural": "o tawa e lipu tawa {trash_term}", "trash.context.singular": "o tawa e lipu tawa {trash_term}", - "trash.dialog.disambiguation_warning.plural": "ni li weka e ona tan ilo Tagstudio tan nasin lipu sina!", - "trash.dialog.disambiguation_warning.singular": "ni li weka e ona tan ilo Tagstudio tan nasin lipu sina!", + "trash.dialog.disambiguation_warning.plural": "ni li weka e ona tan ilo TagStudio tan nasin lipu sina!", + "trash.dialog.disambiguation_warning.singular": "ni li weka e ona tan ilo TagStudio tan nasin lipu sina!", "trash.dialog.move.confirmation.plural": "sina wile ala wile tawa e lipu ni {count} tawa {trash_term}?", "trash.dialog.move.confirmation.singular": "sina wile ala wile tawa e lipu ni tawa {trash_term}?", "trash.dialog.permanent_delete_warning": "toki suli! lipu ni li ken ala tawa {trash_term} la, ona li weka lon tenpo ale!", @@ -299,6 +335,9 @@ "trash.dialog.title.singular": "o weka e lipu", "trash.name.generic": "poki pi ijo weka", "trash.name.windows": "poki pi ijo weka", + "version_modal.description": "nanpa sin pi ilo TagStudio li lon! sina ken kama jo e ona tan ma Github.", + "version_modal.status": "nanpa ni: {installed_version}
          nanpa sin: {latest_release_version}", + "version_modal.title": "nanpa sin pi ilo TagStudio li lon", "view.size.0": "lili mute", "view.size.1": "lili", "view.size.2": "meso", diff --git a/src/tagstudio/resources/translations/tr.json b/src/tagstudio/resources/translations/tr.json index 14f1834f5..44a8ee9be 100644 --- a/src/tagstudio/resources/translations/tr.json +++ b/src/tagstudio/resources/translations/tr.json @@ -136,8 +136,6 @@ "json_migration.heading.aliases": "Takma Adlar:", "json_migration.heading.colors": "Renkler:", "json_migration.heading.differ": "Uyuşmazlık", - "json_migration.heading.extension_list_type": "Uzantı Listesi Türü:", - "json_migration.heading.file_extension_list": "Dosya Uzantı Listesi:", "json_migration.heading.match": "Eşleşti", "json_migration.heading.names": "Adlar:", "json_migration.heading.parent_tags": "Üst Etiketler:", @@ -182,7 +180,7 @@ "menu.edit.new_tag": "Yeni &Etiket", "menu.file": "&Dosya", "menu.file.clear_recent_libraries": "Yakın Geçmişi Temizle", - "menu.file.close_library": "Kütüphaneyi &Kapat", + "menu.file.close_library": "Kütüphaneyi Kapat", "menu.file.new_library": "Yeni Kütüphane", "menu.file.open_create_library": "Kütüphane &Aç/Oluştur", "menu.file.open_library": "Kütüphane Aç", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index 35faf5527..2da8198dc 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -40,7 +40,13 @@ "entries.duplicate.merge.label": "正在合并重复项目...", "entries.duplicate.refresh": "重新整理重复项目", "entries.duplicates.description": "重复项目被定义为多个指向磁盘上同一文件的项目。合并这些项目将把所有重复项目的标签和元数据整合为一个统一的项目。这与“重复文件”不同,后者是指在 TagStudio 之外的文件本身的重复。", + "entries.generic.refresh_alt": "重新整理(&r)", "entries.generic.remove.removing": "正在删除项目", + "entries.generic.remove.removing_count": "正在删除 {count} 个项目...", + "entries.ignored.ignored_count": "忽略项目: {count}", + "entries.ignored.remove": "删除忽略项目", + "entries.ignored.remove_alt": "删除忽略项目(&v)", + "entries.ignored.title": "修复忽略项目", "entries.mirror": "镜像(&m)", "entries.mirror.confirmation": "您确定要镜像以下 {count} 条项目吗?", "entries.mirror.label": "正在镜像 {idx}/{total} 个项目...", @@ -54,6 +60,8 @@ "entries.unlinked.relink.attempting": "正在尝试重新链接 {index}/{unlinked_count} 个项目, {fixed_count} 个项目成功重链", "entries.unlinked.relink.manual": "手动重新链接(&m)", "entries.unlinked.relink.title": "正在重新链接项目", + "entries.unlinked.remove": "删除未链接项目", + "entries.unlinked.remove_alt": "删除未链接项目(&v)", "entries.unlinked.scanning": "正在扫描仓库以寻找未链接的项目...", "entries.unlinked.search_and_relink": "搜索并重新链接(&s)", "entries.unlinked.title": "修复未链接的项目", @@ -110,17 +118,21 @@ "generic.missing": "缺失", "generic.navigation.back": "返回", "generic.navigation.next": "下一个", + "generic.no": "否", "generic.none": "无", "generic.overwrite": "覆盖", "generic.overwrite_alt": "覆盖(&o)", "generic.paste": "粘贴", "generic.recent_libraries": "最近使用的仓库", + "generic.remove": "删除", + "generic.remove_alt": "删除(&r)", "generic.rename": "重命名", "generic.rename_alt": "重命名(&r)", "generic.reset": "重置", "generic.save": "保存", "generic.skip": "跳过", "generic.skip_alt": "跳过(&s)", + "generic.yes": "是", "home.search": "搜索", "home.search_entries": "搜索项目", "home.search_library": "搜索仓库", @@ -140,8 +152,6 @@ "json_migration.heading.aliases": "别名:", "json_migration.heading.colors": "颜色:", "json_migration.heading.differ": "差异", - "json_migration.heading.extension_list_type": "扩展名列表类型:", - "json_migration.heading.file_extension_list": "文件扩展名列表:", "json_migration.heading.match": "已匹配", "json_migration.heading.names": "名字:", "json_migration.heading.parent_tags": "上级标签:", @@ -167,9 +177,16 @@ "library.refresh.scanning_preparing": "正在扫描文件夹中的新文件...\n准备中...", "library.refresh.title": "正在刷新目录", "library.scan_library.title": "正在扫描仓库", + "library_info.cleanup": "清理", + "library_info.cleanup.dupe_files": "重复文件:", + "library_info.cleanup.ignored": "忽略项目:", + "library_info.cleanup.unlinked": "未链接项目:", + "library_info.stats.colors": "标签颜色:", "library_info.stats.entries": "项目:", "library_info.stats.fields": "字段:", + "library_info.stats.namespaces": "命名空间:", "library_info.stats.tags": "标签:", + "library_info.title": "仓库 '{library_dir}'", "library_object.name": "仓库名", "library_object.name_required": "仓库名(必填)", "library_object.slug": "ID 短链", @@ -191,6 +208,7 @@ "menu.file.missing_library.message": "无法找到资源库 \"{library}\" 的存储位置。", "menu.file.missing_library.title": "仓库缺失", "menu.file.new_library": "新建仓库", + "menu.file.open_backups_folder": "打开备份文件夹", "menu.file.open_create_library": "打开/创建仓库(&o)", "menu.file.open_library": "打开仓库", "menu.file.open_recent_library": "打开最近仓库", @@ -204,9 +222,11 @@ "menu.select": "选择", "menu.settings": "设置...", "menu.tools": "工具(&t)", - "menu.tools.fix_duplicate_files": "修复重复文件(&f)", + "menu.tools.fix_duplicate_files": "修复重复文件(&d)", + "menu.tools.fix_ignored_entries": "修复忽略项目(&i)", "menu.tools.fix_unlinked_entries": "修复未链接项目(&u)", "menu.view": "显示(&v)", + "menu.view.decrease_thumbnail_size": "缩减缩略图大小", "menu.window": "选项(Window)", "namespace.create.description": "命名空间由 TagStudio 用于将标签和颜色等项目分组,以便于导出和共享。以 \"TagStudio\" 开头的命名空间为 TagStudio 保留,用于内部使用。", "namespace.create.description_color": "标签颜色使用命名空间作为颜色调色板组。所有自定义颜色必须首先归属于一个命名空间组。", @@ -215,6 +235,7 @@ "namespace.new.prompt": "创建一个新的命名空间来开始添加自定义颜色!", "preview.multiple_selection": "已选择 {count} 个项目", "preview.no_selection": "尚未选择项目", + "preview.unlinked": "未链接", "select.add_tag_to_selected": "添加标签到已选择的项目", "select.all": "全选", "select.clear": "清除选择", @@ -228,8 +249,10 @@ "settings.filepath.option.full": "显示完整路径", "settings.filepath.option.name": "仅显示文件名", "settings.filepath.option.relative": "显示相对路径", + "settings.generate_thumbs": "缩略图生成", "settings.global": "全局设置", "settings.hourformat.label": "24小时制", + "settings.infinite_scroll": "无限滚动", "settings.language": "语言", "settings.library": "仓库设置", "settings.open_library_on_start": "在启动时打开仓库", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index ce362ef22..c49d3816d 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -25,8 +25,8 @@ "color_manager.title": "管理標籤顏色", "dependency.missing.title": "未找到 {dependency}", "drop_import.description": "以下檔案與文件庫中已存在的檔案路徑重複", - "drop_import.duplicates_choice.plural": "以下 {count} 個檔案與文件庫中已存在的檔案路徑重複", - "drop_import.duplicates_choice.singular": "以下檔案與文件庫中已存在的檔案路徑重複", + "drop_import.duplicates_choice.plural": "以下 {count} 個檔案與文件庫中已存在的檔案路徑重複。", + "drop_import.duplicates_choice.singular": "以下檔案與文件庫中已存在的檔案路徑重複。", "drop_import.progress.label.initial": "正在匯入新檔案...", "drop_import.progress.label.plural": "正在匯入新檔案...\n已匯入 {count} 個檔案。{suffix}", "drop_import.progress.label.singular": "正在匯入新檔案...\n已匯入 1 個檔案。{suffix}", @@ -40,13 +40,22 @@ "entries.duplicate.merge.label": "正在合併重複項目...", "entries.duplicate.refresh": "重新整理重複項目", "entries.duplicates.description": "重複項目的定義為多個項目指向硬碟中的同一個檔案。合併這些重複項目會將其所有的標籤和元資料合併為一個單獨的項目。這些並不是重複的檔案,重複的檔案是 TagStudio 以外的重複檔案。", + "entries.generic.refresh_alt": "重新整理 (&R)", "entries.generic.remove.removing": "正在刪除項目", + "entries.generic.remove.removing_count": "正在刪除 {count} 個項目...", + "entries.ignored.description": "如果檔案項目在忽略規則(「.ts_ignore」檔案)變更使其被排除前已加入文件庫,它們會視為「忽略」。為了防範在忽略規則變更造成資料遺失,被忽略的檔案預設會繼續留在文件庫裡。", + "entries.ignored.ignored_count": "忽略項目:{count}", + "entries.ignored.remove": "刪除忽略項目", + "entries.ignored.remove_alt": "刪除忽略項目 (&V)", + "entries.ignored.scanning": "正在文件庫掃描忽略項目...", + "entries.ignored.title": "修復忽略項目", "entries.mirror": "鏡像 (&M)", "entries.mirror.confirmation": "您確定要鏡像 {count} 個項目嗎?", "entries.mirror.label": "正在鏡像 {idx}/{total} 個項目...", "entries.mirror.title": "鏡像項目", "entries.mirror.window_title": "鏡像項目", - "entries.remove.plural.confirm": "您確定要刪除 {count} 個項目嗎?", + "entries.remove.plural.confirm": "您確定要刪除 {count} 個項目嗎?硬碟上不會有檔案被刪除。", + "entries.remove.singular.confirm": "您確定要從您的文件庫刪除這個項目嗎? 硬碟上不會有檔案被刪除。", "entries.running.dialog.new_entries": "正在加入 {total} 個新檔案項目...", "entries.running.dialog.title": "正在加入新檔案項目", "entries.tags": "標籤", @@ -54,6 +63,8 @@ "entries.unlinked.relink.attempting": "正在嘗試重新連接 {index}/{unlinked_count} 個項目,已成功重新連接 {fixed_count} 個", "entries.unlinked.relink.manual": "手動重新連接 (&M)", "entries.unlinked.relink.title": "正在重新連接", + "entries.unlinked.remove": "刪除未連接項目", + "entries.unlinked.remove_alt": "刪除未連接項目 (&V)", "entries.unlinked.scanning": "正在掃描文件庫中的未連接項目...", "entries.unlinked.search_and_relink": "搜尋並重新連接 (&S)", "entries.unlinked.title": "修復未連接項目", @@ -67,7 +78,7 @@ "file.date_created": "建立日期", "file.date_modified": "修改日期", "file.dimensions": "尺寸", - "file.duplicates.description": "TagStudio 支援匯入 DupeGuru 結果來管理重複的檔案", + "file.duplicates.description": "TagStudio 支援匯入 DupeGuru 結果來管理重複的檔案。", "file.duplicates.dupeguru.advice": "在鏡像之後,您可以使用 DupeGuru 來刪除不需要的檔案。之後,利用 TagStudio 的「修復未連接項目」功能來刪除未連接項目。", "file.duplicates.dupeguru.file_extension": "DupeGuru 檔案 (*.dupeguru)", "file.duplicates.dupeguru.load_file": "匯入 DupeGuru 檔案 (&L)", @@ -110,17 +121,21 @@ "generic.missing": "遺失", "generic.navigation.back": "返回", "generic.navigation.next": "下一個", + "generic.no": "否", "generic.none": "無", "generic.overwrite": "覆寫", "generic.overwrite_alt": "覆寫 (&O)", "generic.paste": "貼上", "generic.recent_libraries": "最近使用的文件庫", + "generic.remove": "刪除", + "generic.remove_alt": "刪除 (&R)", "generic.rename": "重新命名", "generic.rename_alt": "重新命名 (&R)", "generic.reset": "重設", "generic.save": "儲存", "generic.skip": "跳過", "generic.skip_alt": "跳過 (&S)", + "generic.yes": "是", "home.search": "搜尋", "home.search_entries": "搜尋項目", "home.search_library": "搜尋文件庫", @@ -131,6 +146,7 @@ "home.thumbnail_size.medium": "中縮圖", "home.thumbnail_size.mini": "迷你縮圖", "home.thumbnail_size.small": "小縮圖", + "ignore.open_file": "在硬碟顯示「{ts_ignore}」檔案", "json_migration.checking_for_parity": "正在檢查一致性...", "json_migration.creating_database_tables": "正在建立資料庫表格...", "json_migration.description": "
          開啟並預覽文件庫遷移過程。除非您按下「完成遷移」,否則被遷移的文件庫不會被使用。

          文件庫資料應該是一致的或者要有個「已一致」標籤。不一致的資料會以紅色顯示並會有「(!)」標示在旁邊。
          對於較大的文件庫,這個過程可能會花到幾分鐘以上。
          ", @@ -140,8 +156,6 @@ "json_migration.heading.aliases": "別名:", "json_migration.heading.colors": "顏色:", "json_migration.heading.differ": "差異", - "json_migration.heading.extension_list_type": "副檔名清單類型:", - "json_migration.heading.file_extension_list": "檔案副檔名清單:", "json_migration.heading.match": "已一致", "json_migration.heading.names": "名稱:", "json_migration.heading.parent_tags": "父標籤:", @@ -167,9 +181,21 @@ "library.refresh.scanning_preparing": "正在掃描目錄尋找新檔案...\n準備中...", "library.refresh.title": "重新整理目錄", "library.scan_library.title": "掃描文件庫", + "library_info.cleanup": "清理", + "library_info.cleanup.backups": "文件庫備份:", + "library_info.cleanup.dupe_files": "重複檔案:", + "library_info.cleanup.ignored": "忽略項目:", + "library_info.cleanup.legacy_json": "遺留舊版文件庫:", + "library_info.cleanup.unlinked": "未連接項目:", + "library_info.stats": "統計數據", + "library_info.stats.colors": "標籤顏色:", "library_info.stats.entries": "項目:", "library_info.stats.fields": "欄位:", + "library_info.stats.macros": "巨集指令:", + "library_info.stats.namespaces": "命名空間:", "library_info.stats.tags": "標籤:", + "library_info.title": "文件庫「{library_dir}」", + "library_info.version": "文件庫格式版本:{version}", "library_object.name": "名稱", "library_object.name_required": "名稱 (必填)", "library_object.slug": "ID Slug", @@ -188,9 +214,10 @@ "menu.file": "檔案 (&F)", "menu.file.clear_recent_libraries": "清除最近使用的文件庫", "menu.file.close_library": "關閉文件庫 (&C)", - "menu.file.missing_library.message": "未找到文件庫(路徑:{library})", + "menu.file.missing_library.message": "未找到文件庫(路徑:{library})。", "menu.file.missing_library.title": "文件庫遺失", "menu.file.new_library": "新增文件庫", + "menu.file.open_backups_folder": "打開備份資料夾", "menu.file.open_create_library": "開啟/建立文件庫 (&O)", "menu.file.open_library": "開啟文件庫", "menu.file.open_recent_library": "開啟最近使用的文件庫", @@ -204,17 +231,23 @@ "menu.select": "選擇", "menu.settings": "設定...", "menu.tools": "工具 (&T)", - "menu.tools.fix_duplicate_files": "修復重複檔案", + "menu.tools.fix_duplicate_files": "修復重複檔案 (&D)", + "menu.tools.fix_ignored_entries": "修復忽略項目 (&I)", "menu.tools.fix_unlinked_entries": "修復未連接項目", "menu.view": "檢視 (&V)", + "menu.view.decrease_thumbnail_size": "縮減縮圖大小", + "menu.view.increase_thumbnail_size": "放大縮圖大小", + "menu.view.library_info": "文件庫資訊 (&I):", "menu.window": "視窗 (&W)", "namespace.create.description": "TagStudio 使用命名空間來區分成群的物件,如標籤或顏色,以便這些物件能被匯出或分享。以「tagstudio」開頭的命名空間是 TagStudio 內部使用的命名空間。", "namespace.create.description_color": "標籤顏色使用命名空間作為色彩群組。所有自訂顏色必須先被放入一個命名空間群組。", "namespace.create.title": "建立命名空間", "namespace.new.button": "新增命名空間", - "namespace.new.prompt": "新增一個命名空間以新增自訂顏色", + "namespace.new.prompt": "新增一個命名空間以新增自訂顏色!", + "preview.ignored": "被忽略", "preview.multiple_selection": "已選取 {count} 個項目", "preview.no_selection": "無選取項目", + "preview.unlinked": "未連接", "select.add_tag_to_selected": "加入標籤至選取項目", "select.all": "全部選取", "select.clear": "清除選取", @@ -228,15 +261,23 @@ "settings.filepath.option.full": "僅顯示絕對檔案路徑", "settings.filepath.option.name": "僅顯示檔案名稱", "settings.filepath.option.relative": "僅顯示相對檔案路徑", + "settings.generate_thumbs": "縮圖生成", "settings.global": "全域設定", "settings.hourformat.label": "24 小時制", + "settings.infinite_scroll": "無限捲動", "settings.language": "語言", "settings.library": "文件庫設定", "settings.open_library_on_start": "啟動時開啟文件庫", "settings.page_size": "頁面大小", - "settings.restart_required": "需要重新啟動 TagStudio 才能使變更生效", + "settings.restart_required": "需要重新啟動 TagStudio 才能使變更生效。", "settings.show_filenames_in_grid": "在網格中顯示檔案名稱", "settings.show_recent_libraries": "顯示最近使用的文件庫", + "settings.splash.label": "啟動畫面", + "settings.splash.option.classic": "經典 (9.0)", + "settings.splash.option.default": "預設", + "settings.splash.option.goo_gears": "開源軟體 (9.4)", + "settings.splash.option.ninety_five": "'95 (9.5)", + "settings.splash.option.random": "隨機", "settings.tag_click_action.add_to_search": "加入標籤至搜尋範圍", "settings.tag_click_action.label": "標籤點選動作", "settings.tag_click_action.open_edit": "編輯標籤", @@ -245,21 +286,23 @@ "settings.theme.label": "主題:", "settings.theme.light": "淺色模式", "settings.theme.system": "系統主題", + "settings.thumb_cache_size.label": "縮圖快取大小", "settings.title": "設定", "settings.zeropadding.label": "日期補零", "sorting.direction.ascending": "升序", "sorting.direction.descending": "降序", + "sorting.mode.random": "隨機排列", "splash.opening_library": "正在開啟「{library_path}」...", "status.deleted_file_plural": "已刪除 {count} 個檔案!", "status.deleted_file_singular": "已刪除一個檔案!", - "status.deleted_none": "未刪除任何檔案", + "status.deleted_none": "未刪除任何檔案。", "status.deleted_partial_warning": "只刪除了 {count} 個檔案!請檢查檔案是否遺失或正在被使用。", "status.deleting_file": "正在刪除 [{i}/{count}]:「{path}」...", "status.library_backup_in_progress": "正在儲存文件庫備份...", "status.library_backup_success": "文件庫備份已儲存至:「{path}」({time_span})", "status.library_closed": "文件庫已關閉 ({time_span})", "status.library_closing": "正在關閉文件庫...", - "status.library_save_success": "文件庫已成功儲存並關閉", + "status.library_save_success": "文件庫已成功儲存並關閉!", "status.library_search_query": "正在搜尋文件庫...", "status.library_version_expected": "預期版本:", "status.library_version_found": "找到版本:", @@ -308,7 +351,7 @@ "view.size.2": "中", "view.size.3": "大", "view.size.4": "特大", - "window.message.error_opening_library": "開啟文件庫時發生錯誤", + "window.message.error_opening_library": "開啟文件庫時發生錯誤。", "window.title.error": "錯誤", "window.title.open_create_library": "開啟/建立文件庫" } diff --git a/tagstudio.spec b/tagstudio.spec index e64bdecf4..fbed8ea61 100644 --- a/tagstudio.spec +++ b/tagstudio.spec @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + import platform from argparse import ArgumentParser diff --git a/tests/conftest.py b/tests/conftest.py index 54368a5f0..28b1d5777 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import sys @@ -12,6 +11,8 @@ import pytest from PySide6.QtWidgets import QScrollArea +from tagstudio.core.library.alchemy.fields import TextField + CWD = Path(__file__).parent # this needs to be above `src` imports sys.path.insert(0, str(CWD.parent)) @@ -33,26 +34,26 @@ def cwd(): def file_mediatypes_library(): lib = Library() - status = lib.open_library(Path(""), ":memory:") + status = lib.open_library(Path(""), in_memory=True) assert status.success folder = unwrap(lib.folder) entry1 = Entry( folder=folder, path=Path("foo.png"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) entry2 = Entry( folder=folder, path=Path("bar.png"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) entry3 = Entry( folder=folder, path=Path("baz.apng"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) assert lib.add_entries([entry1, entry2, entry3]) @@ -84,7 +85,7 @@ def library(request, library_dir: Path): # pyright: ignore library_path = Path(request.param) lib = Library() - status = lib.open_library(library_path, ":memory:") + status = lib.open_library(library_path, in_memory=True) assert status.success folder = unwrap(lib.folder) @@ -117,7 +118,7 @@ def library(request, library_dir: Path): # pyright: ignore id=1, folder=folder, path=Path("foo.txt"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) assert lib.add_tags_to_entries(entry.id, tag.id) @@ -125,7 +126,7 @@ def library(request, library_dir: Path): # pyright: ignore id=2, folder=folder, path=Path("one/two/bar.md"), - fields=lib.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) assert lib.add_tags_to_entries(entry2.id, tag2.id) diff --git a/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..86d70113849bc00b532902260410728eda880583 GIT binary patch literal 114688 zcmeI5e{37qeZYCV6OX@7woKEsOzWtNVlfgekrYL#Zq}02QD!2M3Pq)M*N!-mCy6sf zD*WMC8MaQ2Q=k}z4q1n-+fbk_x)t3zY`|8a+aD>qENjT1djb7bKYnyUsW<2+VYnW48Mj^jU~~j4Ge5>U7nmP2BmS@Z7~l828{U^aAM;MQ zXFLt($6Y_6D~=HLRq`hJ5b>J*yS6V9pJ#qFEL&sxQ7=dbIgWUCt|x3a#h$QR6ZD4FY!B?ZUT>I$J1K_*K5l7E2YjxV5%6%eq}Sr^8{wjdEc< zQ@+kV&R=JzV1zt@)TtA-j@k=^qbys|pCxwG}Y_?irJ{?6})y(KT5k z$;?)zR45|Tb-q}^y$%<{KA9=!u4c@=xx3fy^!9{SU6o?(TD#fqRIyF>K&WYkx~SXi z?-~sn-D*wfiM#F2A)76g*7!`(G-evLcFU+=Z*+zI?Z$3j=r!6cL*E!2Ap@SEej&Mr z%O7n2sw6^#NAr7@mGs=v+bgGO++26wC9$m|S9^_pvAWX`oAvI%?U;%9fDrfgn?kd1 zVq(!6xE{U31F?ETJX9-gBBBmw%PHod-ECknmO^7YTTK*du_iu}&s8j9rzeq5lgGxD z>X6CIJipvtIwegMchYhf6>s$vggyQ8v6g>7>dJ#)MDnz?O3+9;G|&W?k$UrXbTW zJrH_(mfUt4O>y|7+TYn}+_JR8^GUVV5z&u2yMp46N@1PHen^7-cD;cnB@2mAuMZ@@ z**u_C%rv6qR6WGlDi)sD;@N3j(WpBbV;sTE_^=Q{p;?V97o)eYcg{ z%Lg5CM?~Qz3X}|&E|cR1!QJ@&*k;KbZ&*5>a-9_F8H%jN4L9zi_c^3K{wygI$E&}z zlIm+JZE{{uKiT)8-B??e|e+WfioVnpb&NGEHQ7WUC1{7d*3#oHVm3 zURD#zg(F7Yje1;BA35?8QYSg8Nu5L=@%okBghfxsokY8{tS**X3QQcdJn+n7KdDhQ z&2BZWUz73^^!TU`T{xIg8}kk3Pnln1-p9nz4SawA5C8%|00;m9AOHk_01yBIKmZ6l z-~=WdWHgH3y!HC^MmyFL+pUVQyD8nVMRXS(A(Kg6VYee3c56aY)U{UCq!>k}(VmH# zmnfi@Z?RsxC3baX9w)LDoXC-6ET*e>_k_Apb5pvXvXgV@MYo#gfza)VO+$Vga>6Bo zjN|vdN;S~iMZ6hW*UQ~hQrxIL9n3Q}=2OhCGtV&p#Js?K=K*hGs2&gi0zd!=00AHX z1b_e#00KY&2mpcqLjtGCD4{>0XGi1ylk+%%U06Aw! zRJI800Wyw%(|uq^0Kfl7zw{3uAOHk_01yBIKmZ5;0U!VbfB+Bx0zlvaCV=1n!}I?G z+`v#ZAOHk_01yBIKmZ5;0U!VbfB+Bx0*C-S|3fzb0zd!=00AHX1b_e#00KY&2mk>f z@Zb}`zyE*U`k+W!DOuSNZZrf4kY}?iq3$XV1#zt!GvRLeX%RcVu7SwSaH#9b=xKxM*2Y>Xv!bYO9W>kMTjMmsHREB&iiFc z)JBCI!r!QAveV~$jz9qAicYP0Jlk&H&?Kj3ypCv8t)mKDuB1v`bKTE*9Fd5sT**An z7bO}M;fB~!QTU>61B&(1N+DlRP^>GbtEj5pOD@zU)coYH=a-Al$p^Hkik_eo`L8JA zB2|HVl9n4eS6W-kR0^eHuH9@(hgxY>bcGxU3^GQC$nl%Sc29wpEnOR#8gXzO&X?cJ zl?xjogAV@lR3~NR2v6ImX5izOC{a`lIDcULfTLkvsE1H@1)4C8-M?26nYn z&h%05(}zQdz^&zSRtl9hzS8KSZ!1*C5`O=G-v3#f|8?eHnEznj%lsL$;s31vFPTp> zzs)?y)EF;wlex%z+5flxSC}iz3;vfGo|$KU$b6HzoAdwfZ6*L~AOHk_01yBIKmZ5; z0U!VbfB+D9dlK-HVQp9FymBLL0EI@$DbrrhIUhNpZ|MZFuDW@17K^lfnt+=ODLXP} zPLY?)+b?5Uin_~k8mr5jDW_ayP~JHil@g_W5(Xzqn<9QE8IZO>eE5t0@E$@{+wt)F z$%vdJ?Q3|nV$>ZBH?EIlqij^TaBeukiM`i0AZT2iwAJ8XNY1#kFhY}Kcng8TzR6n# zq;x6m57?b#&?W5#;P?L|^T#&kHRfgX13o|i2mk>f00e*l5C8%|00;m9AOHk_z*|h< z5t59)3%$7Ki<{_o2hgv=Y*v1L^71cs+pWo2cJlJA{R8QzAc&LiKWisv0)@>ge=WDR zwZcovy-r{B6L{+(TYZu*<4i>S{-0#NVq?C~e1-YeTU;eV>NtA<#{sSf~^-Yts9FJM*fId6oIWeOy6s z9S8scAOHk_01yBIKmZ5;0U!VbfB+D{1U&i*YLec)slUh^OXR@{sPYx&P4p z%kIbBoaIyv( zoluPr8m$`^mK!LUOT_1STxNbTk;yN=gXW@~X4dZHK^uZ8WPL z3W&lnusM{>&1d7w*lcAbyOK>U8qIp5(6lhCpk!Rya(o4WT}dr1r&Gpu-0g@M`my3x zaXL0!jL&lmsg=cr<1dq&?lSZD0Vzb$P+M>Ygl7LHO zesn4F`-(Xb&43#XAS7hjrR zL@x7*L@t?2U8X0}6XP~#t=(?Mc00nMZY2tx-d?QNZi(F-lCrBv%AxwE7nf2o#q4RJ zvp?MIJxD5>EhbW_)O>7~4yJ>sv;$G7?OB$zfhAl%o?Beb&CSrEbO@<$H`}ec0j0sS ztR&+2d~Ol7`Xe-#=8!?NvAfrcZFkyF8+w_+Y7NWTES7UwK9xguNnDCt zA9M3)7II~z*2zep=oB4D29Vhh14%WNOeIp8q~AK8D-LA^wivxgcZ**gDZ>4Oj%v>c|M*_ zrcRrj99UOOcJkVSYL-c*)2B@34K52NJ7rnO=a&~!^E0D#XbyMCuF%|X8#<(7vMD8_ zfM+qeuoPvK&Qe1K7*=#eH-}SEV1=fP{AE8q9v{c8G8j{JdqNA%BXu>Tl4Gf9A03F} zRy228Raxn=IDDG*(xEu^_+TJ%(sbp(NsgFiJ&N{Vu%v1$v637lO}iB@%pFhFm3o~V z7frhqO>;=3YDy82912Z470rQ}M8P0UBXacf2u%m$cq|TdO*K=wSIPm;v_r|&+#gj{ zsVmAc&g_UnV6YRax?2CkVa+3y;=@4CQ}SscT<&>o+bw`3wAmZ$J3Kl z+@e|-!WKDRd6=Nb;$yOvKDZ6)N{E90{r{uRPurLed7t;%Jpbzcy8m0Au=h{gE&t>G zH$0DezTmy)9rt{aN%+6ubNOENO}l^S`BmSuzEAl*-oN*~qW`k5z?^kI@7ZQ< zyd@7lP$&=p0zd!=00AIy9}oyrkti{2m-5jGYD$qF3!AacJ19&)OXOV&Rf5G>5xbnAU~n2p0<%_4*D3wOE?cP0e8}7B1)x4@Rz7 zoYZq32vxB(p&Jhdr&t`Hq-GRWR?#R{nIEUZx{4lpV%Z!=O{9sFH^VMVIw$XZ#U`j= zni#&#HOo?=G;zYd*31;er3rc8XZV)XBT_NL<KAG-GvJWmDY2+N(8vk{6@-5wb0O5OoO;pXwK zD4dz3rqnD3$GoC4W+-J~+$$QV4Ves%eMRNe1u8U04B7D*mFQaUa4;9C@i=j8ZdM;h z{o_<1PMF(FmHEO{2+0O!W>xByq=OT)D)mVDn%hg2xh0u-T2^H)NjA`Nm3;^21u7Uv z!_oALMKz=)LvtIcGKb{qVDqTb5h>e&mQjV2WIEU=s+5dSkvL&BAFCSn5H%KeAq{;x zRz>*zzisqB2mruEAOHk_01yBIKmZ5;0U!VbfB+Bx0zlwrCV=1n!}I^oj1LTf01yBI zKmZ5;0U!VbfB+Bx0zd!=+!qAk{r`Q@OyDFC00KY&2mk>f00e*l5C8%|00;nqdm;eO z|M!FsjDY|U00KY&2mk>f00e*l5C8%|00`U{1mOAqzGxf00e*l5C8%|00;m9AOHmZw*;bu z%NDl9Lr$A5FWhW*OhPT8qZ0w}^FP7-z=r<72M7QGAOHk_01yBIKmZ5;0U!VbfB+D9kO;U4GGNDN0h0fY zpa1R5f7+Ng(Es2A1b_e#00KY&2mk>f00e*l5C8%|00=xV1SmU6IPDZc5-vMF|Ig06 zZew0&-gsat2o(VWKmZ5;0U!VbfB+Bx0zd!=00AHX1RNCMB<%JPig3B?@cn<#A%Fl7 z00KY&2mk>f00e*l5C8%|00=zz1n~QR+vtN|PpBaf00KY&2mk>f00e*l5C8%|00;nq zw=)6!{(pq|EByQa|Hgci`8#w8A0Pk(fB+Bx0zd!=00AHX1b_e#00KbZ{|^D*2xXga zsfPn}eU$OG?jk8$fWYSg*nM`&7M3pY(ExT2LD|Ng%BcW8=0~)hr@&$<-&TVe4Txqzs^p}nar}PMHHLoOJ%;WTErTPLX<7@dA`gS zbNnW*YnPpF)Q{f{vPK)`tmAz_fg3S=Hzm2TkYhuxa@i zv1yPoGivu_W=cI}X39-)T-7br+$r?#!wkoAU5t**@#R~3qxES`yOgVQoWA4q}y>_R! zC$#FS6l>So&331XZMp|SO*7O*-DZE+Xwc|ZYeG-lZFdgYY^k)yXNsmV)2OvuM*VuD zE9`GKcKbrF(QX;~#^4AU@C5Y>$u(U5VEb1k5gI(2-?OZw=Z@Z9IZfl{y7MlHZ5_GV zYwU~Forc(~cL#3AOvDF-xVPUFntc-!i`KyP=p7!2)f?iWT5%H*bvRp2F$e8#1B0;? z8r#`wqEL%9@sWJ4Vi7w%iF}$oHm+2MOlIc!<@VAkX`;B3mb<8UtEV9B>6fQX_zZ%# zH@e40>0mTU+$l-uw%hHdD6|xM=DSlSfM%J_&~lVnFIZ}acyRjRH9s93A19upC74Pl zRemrgd|C&#oHOw#-Dx-LV#hEAnTF|s(A%@*w$o^e!zb1L&Q9Z&r5&D6spb>D6709@4KyiPNQ8QQAo~OHZCJ3?AjD=$#5w5$%9T~ zU+5f8TAFo5v0mfa96ccszbSUQXrbt;KRqVeYAR+e&H8H1O|a$l%qrA1)m>gT z+|fCYBbW;h^I9#k8dtBpM?e|e+ zWfioVnpb&NGEHQ7WUC1{7d*3#oHVm3URD#zg(F7Yje1;BA35?8QYSg8Nu5L=@%okB zghfxsokY8{tS**X3QQcdJn+n7KdDhQ&2BZWUz6y;866F1v*GvuF6Sq0{=f14ig(iU zkM6Izf8X^fR|_S<2M7QGAOHk_01yBIZxw-~t0C!;;`V#A=T4#75W4b91m*I)Dd577 zQ;GmbTtoKkhK5?$e29%H3bWX`mrYGi;V4Od1b0VM=T3Sm!M55{x8WxAHhsmBI{Rj{ kEVt|5)~`6=T?M?WkY4=Yr{W=V*n_u64DTv_*4I4$14p}@n*aa+ literal 0 HcmV?d00001 diff --git a/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..b4c2833bf1f1a66f98a3635187dc76f990e32aec GIT binary patch literal 114688 zcmeI5e{37qeZYCV6OX@7woKEsOzWttVlfgekrYL#ZkCbMQDGvH3Pq)M*N!-mCy6sf zD*Rzv8P-mY-JuwU4q4X?D^Q?ayA|C!Y{Ob$+aD>mu4~s}1=68S|LL${&5&X%ie^QN zv|IPy{XXibcn!MD&X=*nyZ8Nm-}}DL`}Mtd^xfu@YmJ`B?zB7mLXS<_Mr{OP`&pK? z*=%w2_X7H>e*EZwQcut~VK^Rk7`I)1Z*&AJGe5*S7nmP1BmS@Y7~i+O8{QW^ANEeT zXFLt(M_fOmD~=HL0(q5m5HHxjZ2K7TA?62iwnx1n9ppIT*}0yu-4uJmZdGjcIt{UF zI6Rjt^O*|IRx;T&o;3*AX}1fXHtKAlSm9UsGFvQF*y7gO+AQmK;hYYK2{+1x^-TF1 z`viZDot871WmStPHqV#Jd||bSH57#?Tjuk8nJ?z}P3%XPoo>{R>1vr6wUt^)2JUF; zoFiD^2wS677jJc+ZlZRo3jJPNIAu*ii36tr6S)E5bW7M5XOU&}_RTRm7zhw|pHy1`(5kCatX*q2+np-5=^h9*%}^J0oBds* zL8Du(2|aPQ-8p2lrP3OoDVoL%lJ7O@b+KhFt+iXm-0F?4u)p2d?F+p|yJhG(gX3qw zPt!>2V#=cnHX^72wci?o))E)yu zJQi*U&Ay44MO)!|^bQZi>UHr@t+4&FH_zZ$KH@e41>0mTU+$l-uw%hHd zD6|xM=Ce~KfM%J_&~gk~FK23pcyRjRRX-gZA1Ch85=^C&DqoBVpVombXH+~&ciPRm z*fC5&reS&@^!6;d?KGO=@F};yv(va`X@}>NYON!puR6PeqLWHtoyUGig8g>AfhHvj ziGb!-bMkA=16svQBU(m#DANfq}p{L`nY+Pha*tIF#lHpM9lLwu~zR)?Gv^49AXcej>u`0#b z<@IE{-9eqea_KT*#tzT2ns>)0QC(sTMzmDp;)Gx)eU9L2WEgB6l^mGJIQ{Q&)4_0< zIFf^i8)B!67K*O=?J?0-Q!#64)>mt8f-SFSR)Mgo?((wXj?Q@;!CZKl*J_c~xO(J0 z8gWX?hT`%;N8Axn@QK1G!=cOMxIu6)zCXTMGDjnpj;CBF1%iemt8v4L`{($LciIw?5+16IjGHJoFgmU1BQTL)ASJX$2yoA(Aj%rdT(MP;~WH({a({U%!&Md2orIrE{2Q3dg zv)E5+R86y6jqAsxd}Td8>O%((X4J-fjrn8dSDAM*adZM7AOHk_01yBIKmZ5;0U!Vb zfB+Bx0uMNW2?rUC;`?&Fe!bC-wZwL-BJ6HTXKWFjMMubFQdijR2#4L8&=hs8RW&I_ zk!iGNqUI$E=+<1U*KUbjU75#;Yy~HBBpHk8>fJq|Zq(eA&Zq3;9J&pz=6N7=dt%d& z--euUi6GEGv9i^n;5DG1b_e# z00KY&2mk>f00e*l5C8%|;Qx@oX);RaSLk@7!4V{rh9qrkVI)MR4e5q`1nLYKGf3nu z12RC)84{H(0(*drf00e*l5C8%|00;nq*GGUo?+Aw{oJ6j)j^B5* znQQOwqjykrEBw_;b*)h3FR~#`W23ammyrf~7QWcgG{V#898*&$kJT-Hvr^5JD}~L< zY9GBYjLmdJ*ywTroHoAL;+{W3na(U~S)yzAP1M9-B z@Jyp6YPp=B49K};ij~JoYo*m|nO1KPy}fAnP}2!rIO7O~P)Sdf*0$F9r`pZ_zMjLm z=$PZurLdF0uL-G^dGyYuO@3tsy*Y#}i|F++U9qmdKqMGFZBX6XSSw{#6xFSRW*a>< zPE$R5;gnqFW@)R8-bdE%cWQc>f%Bt|U=Vq+ML|Q3WnnQl+lB?&my? zNJLexWS-!Q5{-&*U2Lf+d{MUn#d>L_kS{1G)|I!bsH)yeF4QH|{N%^;%SGqp7qqB~ zo}d%?uPEXoRe^hwmK!-&T3gFh3Z-JM-E2xPwbH8S3ONuMWQ<-h$8Q$fJq22}baiBE z#KCbmUw$)JE^OcoR>W?t(>TDdf0D95YSdZEF*b(qZI##1A1!C{0x36$+?fZuv3=Yw zNnPkwVULx{nLg@$`fvylxV2o)N};mGR~kL^v_f?(;rsvR{hzY=Ut#{4`FG|W%%3nD z{!jV;g82mVTg+Xi#(0?<%thvl{=fCV#9U#X_rJ*S%slgb=IhM8y#Md@%>-Z#1b_e# z00KY&2mk>f00e*l5C8&iP6A#stnCV&S5Bl2pwK8eW!mdG=OZWdEuA3NRX1Cdufd=*B%?+(f?}KtF}qto+>M;0&*aWcq$cRhd0KoVE?aWIy<`wim_y7SQ z00e*l5C8%|00;m9AOHk_01yBI_b~yFog61}Tjer(p2n_+KqrM_p;E}KN%#Nl%*!_B zW#)VLaRtG3AOHk_01yBIKmZ5;0U!VbfB+Bx0zd#0@aQ`Lb~ichQZ@ka{r^YE|F)rj z{r^#>$2`WI_kYL#IrQZJ1ApFs&i5_fXMMl!6MakGAA7&xeZTh|Ue5EX=Zl^Xcxs*{ z59$7j`}f_yHw30=JY#rm57D(b?^0->S?kO6KO5pN%hLvz3+XN;b7DX{FKF=+rRu&hQQwzrOnvH#{+O45vE}lhgnB$fcJThBO8hIXy&1UWwtx$am7~7 zmLvfe&(E*Sr#OCPaXyjA%+oW`8MWth#CmL~d0DKpN*1WT%Q%arWIC~!oTJ0huptWz zr;#leUz%S;F7t^*E}2VRrYF)9<2Gll-EPKqJHnxEB?_J1UaZ${iQOEMva3kSq57s5 zmr^ms?55D!A8z(`B$dq;6RA{cJ~m4S(?L|)fhg4WEKAzJ5-uOlEiUKgX6R5lgw(g2 z?N;4@(%@NE5^;P!w}@K(VVX;G$e`KS-Rs4+JMEi>US_ab!*Vu@|+;TpV$)uMe z^rhG(#g}SRxFMoOHw-=5LULwJ5u!OMnO)31#L|Hnu5e4Zajb|UO5@`53)$tR`3ZU= zrq$LwcNub*X0LcY#V@3%FDl-*n~fWW9w=*_RF)b94H*A6Q*X{>UrLQaqneFDB2JYGGbmOjgqRl3PYbne-SvH8&+;#dFNy%3?B8 zR+oI9kEfHV(JxST{&=)Bc@r8qCFTasoF}cBnL^;Zp908 z$5VBsUMI&z(=J8R91^LTQbZ(&Leow~b6_S>Fi6vg9Q`~@)4@0%ivwL#%~bA{a=MUGbv_uQZHzt{g!<|XEH{>#1s zbJqQwXPdeHhFthSp+Eo#00AHX1c1POKp;#-qQtOW%10)sDMflLY{oWkyFg7Sa&wrB zg;y?8oGKiMjIsEwx_B@Y#^Pl?Yjf<2g-g279PVOaS{IrlTr6DF>pKwCVrfb@HHWcS zxS%^c7`b9`QqOrHRK?PSZaf&AVsU(uno(F;MWa|{ew+&HDthRNWpf-gktR;w47)7p zoV@cDo1lVeV)!=KEK7yb#0mRaGgB0oCggpe;agGGh@lT;+BI5K2^=%EW#AV~}nPDQARr0l{FjkieU58F%P&Qrk{VYzuUIZ0iLNm&ow zEsBJwK#Z{37Gf__6S8s4+_~lbnhB{`%Uz#~<5Va{Sa0)$!&De~J7inu0!u~a6qe>; zu3Tr2k5fUcV6|a${vs7dvSZs~ifPD@w|Sf^3g;yIp?fdE^HgAtu-s`m8=*MW?SZkb zqWCI;n z*>`YWpn`EU98I@aR6|-aG`Eo|b4acZHjgSDk+L0V8C6J0rh|>5O34Tni4#`yv8rJY zQDboz($J@4RfO;V+eYt$003MB0zd!=00AHX1b_e#00KY&2mk>f00e$w0{H$vT>t;X z_`nbd00AHX1b_e#00KY&2mk>f00e-*eL(>3|KAtQ1Wp10AOHk_01yBIKmZ5;0U!Vb zfB+D9T?F9z|8?O5V;}$ofB+Bx0zd!=00AHX1b_e#00Q>~0l5CZFPaIQ1Oh++2mk>f z00e*l5C8%|00;m9An>{f;Ol>q`Jj#Y8uLNsuU=PHUQ?q(HjBq^*_OU&xZcM2M7QGAOHk_01yBIKmZ5;0U!VbfB+D9 zkO;U4GGNE=0wn(pU;o>g|Fki$qW{4M2mk>f00e*l5C8%|00;m9AOHk_01$X!2vByC zaM~$?BwXmX1Ni#?6&v#k^XdarL8u5200KY&2mk>f00e*l5C8%|00;m9AmE?~Ct{r?f>FY)jH|CRYV^LOYFK0p8n00AHX1b_e#00KY&2mk>f z00e-*{~rRr5z020Rq1e!0xkCwy<=F-wj~*5R`4)sk{}y$Na!H zN>^;m0`u2?jv4WP)yMe0?cMOc==rdB!ad_@I6vb05nXYFs29kqq=R_D{$<<8hz}t% z>2uTz(m{?Ro}KFn+f7v3ZdGjcIt>xMq(D17mn-v`3eQ$D*)^Uu2-s=23!gUXY@t}; zSNSqqELGUz*4o-EEB!P97xL3A!-c&)9O8g$qg+_el&`T*@YmRB+0j{6wTL1YUHMX( zFRT`^hN2K<%Y2?M^Tiy$iR;>BryKQSx>_biZH1g<;Epms82R%8N7x#zx_GPmbQ8S; zqAK)zZRxyfpjeF?PC`e|1mqUEqP56@&_P*CEutKrJ=P+M0Jn&IIE)E;nMAWbOlSh8 z+nqx; zTPm&bnWAxAx{!RYQLl?FYiX_BGUirqbcOxx#%^EeHQFsh&lwy)1Ad}jBRP(}8|)ob zNrZ-$R;yW7(sM^|ubifFW8QgGB40-p={5Gn>P|y!*1H3zW2W{P5aO|LLumF*#4Oqh z*Q0lMAXcx7hib)5ywt&NHOaXS+T8{QV<|ND0;_36E!M=>pX(=a^{dV7}R zcc;-5hflfvot?%lOFKNDRBIg(ebw0&6aiHV>pb>D6709@4KyiPNCY&unv-8^9?&Xg z8qsp99)N5W3r}wG>@==u)E$j6j$medSb(9>tj3jt(c4$NbTASj?quW{0mW_dER3TM z`N(H#4?P`sW#b}a!mdr>mJEk-pFHR^_Jz*jq@`I`M5|C8iB&1aF0Uus?GEY$mP?lj zGj@2E)x0}4iRuz#FruXz7bgTe>2m~EBg0_psN}#z#_4~Ln+}G<#E~3C+z>lmv`}=_ zZ;y$#nu=LVv%Xq$6Kr`svk11$v97#qxTA9(M=%#2=CxX6HLf0ck4Bu*vZ1(q&=Ge; z6nvsE%5dm1Ic^Z#i|>zbmdw$JsnJx&Q!YnvcpzvfvKlv>xR2iHkox$uq}0dtl~g~X zP0s7-C;L9M8%xWy63R_4M@@}|;;g#3BlMd+%ienLk%LMlNuSgS^}3)2#c_`mLpYD- zNofYYEk^>%q@sLLCK}p=Gjs+XI>+|=D6+B&X-&a`?AI%;Iek>uLhmI^2FEODkt zg{15_!?4L&2Mj^cA!v)CFFRx_8erI~qQG7>*oq)shP`RAZZFywLzm43h7Ik4Vji&W zdF~H+M;+ay-IiGUQkHr5{C>aZ`Tai6-+NBvtv|P1@2b&Tt@fVMji#9s49hZ~j7AxT znIymCb{*>o+p*nhl|33dJcZ1(>#knXkc(N(q@YFF9WR2p@q zqjoHZ%1XJiPMzs! zy59I<_p}zuEAzcj)MmF`*F!N5!$(3f1T>UKxJI?^y)VD@Oy;7d#RrOFFydY&)|h)9 z6GYy4COjHml31qRtf?<`UTl!gvZ)+&TiW?1^~TWOlr1M`4qrZ{(|Wo`YhP&-RjuNU z!_X00hJe!24~H??-mN!k!-Teq?&UKVHI0ri>*+XG{vftqK2~fbdb6oyLO+k05vR_~ zI5~Z7Y&1MO%Ra9WGf;Qb7Z23twmNWn%0_NLI72(f9I=ewzk5;$heGUUpEGNL;_r;v ztaZm7ds@^=vUq)Cb!ml|UahQbJhBgVc3bW4uF|ZTJ(Oy1w;HYXCUvc|uWa`^Z(r1D z9PC&P>YdGPrK|3=+V`TR)z#%nam6+S5czJsR#ThK((P8$np>^jQTDd#I|oX)-fCJ} z&XLNs`$<}jmO#B5Y#p1Lh}3#7s=279M@Ear69TQwS453`jRexI@2Q)&>T09b88|&@ ztB(O8?F)C5#(|C4kP&X^sAzU{$FbB}$xh>1GM==H|nc%MC?clRZ|p{Xx*jZnM& z!zXNES_IA^b~g788r^!M-c+4y_yf)iW*Gl#3~mO6@WcfBSwUl905aZ0TkJh}j=hey z$Xl&OO>JA6sjcbnE8ShQaoYs9>J4>hBj_C5x>bM4(T=vc&F!{I-fGcBG8u0yU9C_* zG{Ii0RwoVDL86fUXHWiljj0!ytu^VX`uItUy}q*a-1SOyh6YL|p@cXZE=~-agbbRS zvT<jtO7F7)TYdD?X;ZWgL^is;Ztlo%DEF!Tc70E2-!@U;su8g%rSa=y&Q_~U8i8Zuw_&CZ&qaIQ9hu(x66=K3OSKMQ1a>+w8om-62D^_+ zpS*OO!Qb`@;Yfr%)F+xdYP&;5*^c?{v(YwFDeI}*>JTpanCGZp!A7|8T^u4OxcE`)&1>N){+&srz5@5lE~t*_c=EJw#Po<}VfEJbe0 zmJ{z`azq<#?&q{NZge2yZPey=uX7C@ZHA5~2M1)Paav~ELNV6TwoYs9#WWY%i-kw^ zV#CYLlyTrrjJ_Xr*-#e_{hZe654SZS{np(-;%!*^>9jI?XO7`gsiVNgp_d2kMf{_B zQBSk8Dff>@$n~C^Uh^H!xPXy_n=m9C7NohxuNW=YiH3Fauy%q z(&@g!PFuOx*;X2AU+apQl;F7>`FM(%m#UCYg(kYKrrPN%S7>5%gCY+ z>TNBIjB`0lx@8}MKgA_15`D{n3vu(7L}QD<72;(26>I}L0`&er`O-hUKmY_l00ck) z1V8`;KmY_l00ck)1dcHQdjB8S|Hrt35gG`900@8p2!H?xfB*=900@8p2oM6e{zo$a z0T2KI5C8!X009sH0T2KI5CDPWPk{dYKPSGyi2q8y0q~9EA1@*V0T2KI5C8!X009sH z0T2KI5CDOXIDv~C7ym@%=0;^@eQ9-Nb9rffvcbIVXUW;p%F@PCaaq6r?-Cy{ z;sf!$$2UTF4gw$m0w4eaAOHd&00JNY0w4eaAfOTOaTBbu2jKE@6CPs+fZqSV%>5T5 z{+;+0u`50!o(=w6@LRzz2ls>3;F-XW0&fQXAfN{F{-62Z_J7|0tA5G%(D!ZM7kt~k zypQvK$NMMV-|{}|l{^nT-}JoZdBKx%|EK$J++TFxa?c76g?EH62rmdTqyISiwb2(x zvm-wnd28hJBR5B4{JZ=ce3#FWqVWO&5cp6CWk+px6$jI+xeJ@(gc#6A*KsbK1WP5nY`86t;T^C`YQC#8kVVs@dBS+JJZsP8#Nw@i{HxkT!)EEQ4}Vpd37dEQeSjn-X<0-x6eQmQ1E zlOU854IDwnf`^8BnYo*O6XTa8w;WfDtD$ULMn-Dh*;X2AiKa`k zOh%jh%#;vL(11yIXEo!L}nPNz%y>w29BqEf_L3`(*Sv%r$ z$w-&76}g;RNKXi{`F=aF&qv17v-MV>%}Ld2K36Kn&kEsr3cgV%)zR;4J$;fYEmjMe zYIS~Gh|E)ejzTxJP2h!OzF5ts&)6KY&pD=v-pA;?lO>2qDPg0O&h2{%N0as2_oCWF zm9JE*)rAYEh0r{$*1dYOdnACXBvH!dtJTs%CL~Dn{eC|%yV(5EXPCTPt>&`nQ?@AV zbBoPNn_tQWVpPnH3Dfh_8df^S49+YzGh=qCRx5HYojGZ9a$sJu+39l&iL98(<({yW zH#jZW?2KulS}iPOlCvj-@p;-HJ4$1(WoeLx$-0(I7ChPXLOw1Ujb)n@V42Yk-7-xj z3o9~YR4)aE33-Cn%HW!+(^Z;e9I2U0Dt#?A6A(f&twnpYHIFc5ykD+N_5}BIXib!7y&A1KC zfsw?(pbaDX>gS>$gk{85CB$SpJUOjfObg4hMPIKx#R_Bcm~PeY z+!l3Xi9$dBf7$(2M*MyMYktP}58l5G{=gUU|Eaebd^Y&dciH!*|E7P!_eWwX_-4Qp zco3NJ{?zw7fmZ^r2Yvp(4!q_67jZSX6WI0tis#3kqUST7KlDs_zV3d@d&2#$hx4v^ z=RLpa{;KCY{=4EId%qt1z2H~Gcg1f7F9nvw)85y7TjK2x<-!Mrf&d7B00@8p2s{P^ zB77{)4%?-?JjqWR(j&`eYV+}P{G=hbFO#Y8>GQl~3I|rkRQ$2NcyK99#f5&>_O&k+ z=KDhXa+eBoeW87YONH5fyaS6`D$Vpw?aNpyT={$PpD>2E3bL{YKuDK{bo?{=euQfZ( zOF35G_Zhw=bx|v3xIF$OAIq`Y-p+8PnFt?Fv%~hIE==*UwBg8*{h_DM@u4(3L^vJe zC)2tMLo}Y$${)6uB%S5M3D$A*XljbTkkGOox?2<*=R*nBX6ZXcf@7GLf z#X9c#oS)#w6Rh(#Pb9)eh_^$wb3JOck6qY|ftNBSdy&Tg)&W zx8!Z_=Z3-=&3@?KOZY4wnr9t%T29A!$#i?5uN!%XEQQhh)}XXQnI=;m3(= zU}QF>eoZiz@xc%TKmY_l00ck)1V8`;KmY_l00bTj z0=WPGSX2|71OX5L0T2KI5C8!X009sH0T2LzUy1;(|9>fbFa`k-009sH0T2KI5C8!X z009sHfyaUXz5eIKFEQfx#4m||@mRPFCqV!NKmY_l00ck)1V8`;KmY_l;Fu7&$amh$TK(GIQ!iYZ+ zA0C?k5eNu?00@8p2!H?xfB*=900@8p2!OyS&$?NcYlLS#9v8m<4-EnYKmY_l00ck) z1V8`;KmY_l00fRd0eb(RIdS~sM1&v!0w4eaAOHd&00JNY0w4eaAn^a00KNY|BK|r3 z`~QCy|6cqna)=iQfB*=900@8p2!H?xfB*=900@AUtRhIIlho+ZK* zsgRT%XBaj)YlopII%I7z6#J_~x1s}v{j;LL{%Eij!Lkhlh78O0$2MT-vZcU)pe;JHelx_??NLVT$ zYQDTVXl!pPok6dq^wpmaH(O0p+S%%Qt-Mi-udFTCZpX(N#ILW#O-AuKnj_6A**)V_ z;9jFgH2RxcJFRw8FHldLaxBnpU=*mQ$LEZkl6NC~I-H#?18WwXE2?G1Js z9qM5Jj8j2&MZKJUe2!+Y5Fh4{Jb3mrACV;X(F=pdowh=~Z?s#DzS1|HM)Wc^%9Yic zN=Vm<&-sK_lTh8LU9GLh*RF5G*KVz@F2sETEhzqT<@M^bWvhVZJI3b5`pUKP`it@B zYA?p;2z8oI{rY-sUh}JMteS&s6j)@V;9}H}m&qCZVI>cJ=ILs2&+D+9FRI^HotL z-z1Iz^Kj+W|fjjk2HNRR%gB z{YTK^`w*UEucHxpx7%(iJyV!kLVvF@*fE0JBDmXXD`ScD_wU|qz3ga5V{UV+r;s1b z_%d0_Y^+?X(R!$Y-EOl*0@p#(ApOsp{6WDqbj%V>TBp+}c}d?b-zf30i`dwNf$bC1?%{g(C^rPEL}4@4Hal&od&IwW<3 zJG0ko?KXN3XB?${g^W~9A~vMde{IaU)9sNUaO@2h%(TJ_@nP)_O>ZNKdBPf|ng=if zJL~sEt|rF89--1EFAZnlSG;^A7Gn>ziRQl2>yuHoZ~PQ2w2f5Cdgh)ohKn{#Ij#LH zzN;CKS}zKoNHsRD)<%(=vT^1+NKdHU^-)0$5OU`}<^3=d@0> zIH4~_Em3do1~eDi4frQ@1HG1;8U4hY@_ZO|Sy$%|0&ca}9BipTM&U6!<1AQ4>9i$> zSB~ydsiVNcVVDOEL+(joIMnQK%A@lMa<}KDcYFtPE^Cv+Cjc z2MKOo{01YwC%!BGqxiStpNZchmpDKG1V8`;KmY_l00ck)1V8`;KmY_j76JjD6PQ@F zySqy^O8bI`Tt(KpWE(`BAg9u@(%t@2sk*nl{JDVLTZ1)-u`&*5+GSa$gB&9fafqXs1$V+LEFJ`9(-Hy^9 zDc5LXe1j%RZg(m*Qt$6Hnr6+L>h-M4y-2>oY~;Du=ns^(DZd+9!UfhX({HEhZjkSd z(l04BN9EqsQ+%X7J>n~j_%-qK;w$9$|GzDM@0d3++yem+009sH0T2KI5C8!X009sH zfuACQlkOxtxYZTR@PQFR?4f)oAmR4 zm-sFtzAL_WWF17sK>!3m00ck)1V8`;KmY_l00ck)1T+GEca+ul09-zIRM2+-=;!}e z-2cspe=mMj9Ei_~XG8xP`cCL8p}kN&bSC({;OoKP3@X85;75T!4}2l;^8v~K*#D;g zi~cQt(eL)X<@4Y&d+8V3k~z{f(MaD`7K6Va$ZA3vIUu+thSqeq0& zH2TJq_zN^$lC$JbPO4|>a=m!@ll*LQ)-c>{Y%85X!@=~LCXq6kbXm?y_54!4n9g11 zr<2o$@m{NQ&%yErNtQBlx<<>a=QHJc=@Yz^l!j*ey}hh~EY<7j zQps%E>NH#1U57GPRDmSti0P74ED+ONu4u08o%X&{nH7>OrAu;Ju1eWTu~1Lv3TCsd zhYiBVc=qZLY?UNS>55#UX3NW!FX=J`-*x4RD<3Vcx& zNSTUUO;<=OF6Xn$g^anXjomv+&%v@x)1^#~G(|zGmrLbLwIrL%?DiU+ZKuj!R|S$> zPcNr)Qf)b(&Sc7Iem*&GL{3j>rpB7DE6uo`1@X5;vnXZ@nS6GUk0oQKEF3CLY$dsv z&XX$FGMQ?&n!ChL7p9|(cdOfNr|9#1M^>h%_*RKDPTv|N5q}H$VlJhdJ!tfH$D6%C zq?%bilgs7OsRce#h>+6ul*ZPMV@WrtM5@cxe5txP&rcPmi29v&x6?GCG}Wx4ilkb- znkTJ(k(UY*F=)58cLu3Dz3u~3l$oqnsXShxa;Z|wRWi$|q+Yf0Mw)stlg-Ha!W^F{ zBuGi*okjPaP|z|F>*Xv}Gh}p{zraVb6oG9h8$L=|8_KjXEcXnb$kI|Yf2_?jw9uSU zTc@lZ$yq*}4HL652C8Z-de{lQ|{{MCD(JcrNZ2K zz4qO9>%J)hHSOy(QCh0zbD7%37(bPw!P?lrv)?m?ou*n=QzbIRkWPE)93M-?D3kr( z_I{^%sLmBVUCP(wYGx@L{74S5H8A~c{Ej1V9!!m6}YuFmfdc@M@(?Wosl4*_i zY%8asuC{Q}R!j?iUE97`GPL!zlD0^i^Xav)2A-j-MxC}UniF(Q>ypUOR98gWQfSVr zYuZK<<5hNa5u~ktF7kXtrhU;CnntEtRB8*JIgg&JH69IBH59cq&ccLFz#fE#?lAu7 zvgRVE*TEKfhO!=X>Plw8t>1zytC3eBTUxHuP2T9q~J%OTiWKwC^?l9r51Ba^r(SK>!3m00ck)1dafK z7?()0<8~>pOmnlk^w6@I+I;dHH?7O9%Va9Ne4djGp>1VM#h)07?Mq=QE{(Fbu6?Pn zI1*ZyyHr>h39Tz!D$I}kwJmC?G&eG}E@P>1X;fkR%9V<Lw$H?0|`tii4A*G#L$ zI_~1wqv$+&c(UJqR!IV&Gq~2Xq1ak1*Z+0v*)=OksaC=(@m#L zd0YFru5d=RAG`MwIm?9?S;w80(+N&8s%`7*dfs7E;nx1HE1a6)W{oWDeO_0YG?ilO z_qxVOQzrJlud6(Dj+9uvLO>xnJsYjeFyJ3E+UieX!(f6FyvK3Ya1Cdk6KlG z^BB?zHCtQD7(z}pwKs|(btkxl%sP$7hK6g3o0J8jF&d5y5#IlI1QtK2A_#y02!H?x zfB*=900@8p2!H?xd_)3x{{Ke=2y+ks0T2KI5C8!X009sH0T2KI5I6z^@cI7{Xe3k- z1V8`;KmY_l00ck)1V8`;KmY_jA_3h0e?)*V2LTWO0T2KI5C8!X009sH0T2LzBS3)O z|GUL6GveFgm&HFl0u@FTK>!3m00ck)1V8`;KmY_l00cnb*bunL`j{9aPkFrzQ*Yew z_AD>vGM?S1Jxg%X{y!o93H|;5?~4B*{slS3 z0RkWZ0w4eaAOHd&00JNY0w4eaAn<>OKyZR%rUm2m0R13mzHAC^jtR5$g#fOgi(_Ky zDSbD9%g=I5)T_T0fPVh(7T>1#|L>3=I6wddKmY_l00ck)1V8`;KmY_l00fQ>fe<&% z4Da~+t^gNfhC2oH!npl!YR~_BpAmm3zJGMwLrp;d1V8`;KmY_l00ck)1V8`;KmY_j s1Oc~O5I8q^3?RP${~>5W1q46<1V8`;KmY_l00ck)1V8`;jyQq;0rA0I%m4rY literal 0 HcmV?d00001 diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 65b05998138eefe50fd0c694ec4046c8402377df..5389a26e1071e8c0fcc40a95bfab9779f0bb0c6d 100644 GIT binary patch delta 5150 zcmeH~d2AHd9mnT*d+)s8u8qAu@%RE8A2>1QhO)MoBQ>^b3<*f!OuS<}WxVU`t{qTS z77$fckV2AeORKgCN{>{Oq82wCO>&k@okg12Ygq|>Z^o}k=H^cFcs2Lu|imUibk0>#DRR{|I_p|4L2#~X9$NS(9i`qW!KJGVVA zR{PzzSk=_ykU5U^n3&UXn{U5n_UY=1U|YlDU|Z#Fw~cs5PoJ4EOe3PJ^(`$;p{NlQ%rnB}?()s^avXuWI&n+>Ez3L_YjnLwZ7#O( zft=AcMjEpysX>yNGM$+t!6YMmp!|?HjU3p0K-?mx_PT2Z+Fh$esn(MkRPs`@yn%r? z-Sb7M%9i?>J1=#C22&+;U+OT_rKZ6)>pSdA+vg*#17z?Zc!Yr1>1;O=re2zQ`9=lx zigspFxT$2{apFsJz-|F9!bx}&{s@QRAnYFS*L)qiybj_L@*AQ(J$fV&ciCAh&>Uq; z4K{XJ+^EMpV@7Ynh(;WQIED1(ZB1^OU1SGi2_v3xOL*Ov=#Iu*BD)UYFcdaG5iSet z&jn}T$bf&rPa*aEQ?p$TQ5G^9*bv>!`)U zHhrAacA(IgERg)l6G2B?F{kOKfRJ8u3(kI|RtUiv-y1l>ye=ojf4`UToZXVNOF(oAYot}3UM zJ?f&Ewt;xiafJ~z^@M!GO)?azUDr(c8rqjV+^5BKLa~Eo-CD0sfZx!} zo@j&+>qb&)i}pprB;7{}6{$@Rj|}2N7fDBhd=4LK?ThuAIuS5Rs}Wfz4`Royy4KlE z+}wx7izMXturWND^XS!L8vQy^@ZqHf^SMIVRK%rvGNGyM+S<6JkX%Jts;y-mXhcTG zCYD{vmvCTB#@gu_u2_c0*0`3UUTX~PCBA`hW&Lo{lti;~tpdNFuI)QrZ64;Qb&h;_2n_TvrSfdeOH z4PG7I@sF`foX$u1RJ|ExlOZntl&N*DlTYz$Jr>(QBy}FdN z-tQBM&n?4ID_wXSjt=-+UxCszS*~#2d@962l8eRBY$z@rxHwhxx7y#aUYBDNlM|_qKTpJ(_#J>+??8@g2t$ z`ytyuZM(@^WDTj3d!$#y``If}EKb^THwpgiCBZwSWK?Czf3djPW3X@SdLz8PBcZMB zUif>`js*Mv2<6@CAuCd4AI z_NF55?(6#*tYJ^cSjOF_+z?<0F0(T3B%FZ1u`2WTtcZJ&RhmDC-SB-@!hHiCg|EUU z*uV<7bu4#U4J)99m2W|q4Yg1SrL1@hfDc?C(IJ*gouTj2x9A%zqk5GdroW=kvb^dU zx{H3BUvVWlvh+g_*oL!-9Nc zI!7JHG{ncX9QShEgZQWhaeXRBHAfX-TLQZUxUH_dUvLfL5)d{)QjT_ zwo^gB7&5JMN|SxkictctD;WYhqj`v_K9X_8|{aps@wR&5J=tDy?6eX zM4L2;uRYtkyXXDA&)?tk`Z;>ke)OpQm%Z*4f*=C;*Sg@f(TN=^#Mm#wHY`H$t+5b* zZ^6c2=YYG-rr8zd&ulL(GZ!5cb-VTf_2(S_)E{ze)u(Bh-s86Wyk7LOSAU!Sw|;|Bn3tk1yKiI!9e=XMokc^) zUE6XN4d`dkjAKzN=PP-ws2`%dwf)OzP>)-t>1|;54Gp2=TWh)3Q7>}U4xK~2{~Mp& z!P|Y|Fgl*9Q*9H#Hbo-ut{EU5?8l3JWm z%c~ygHC}WUYOkF^!`2;gDp|X90fj7&(6v*`D4=u@gK*&c83}9qBGZ~M`+eN9G zUOG_7m!Dsyt~c1#FqjagglX(SA~%-;Yi0O2sn?ahFzDTIgu#4tuCgoBAB68pSNnN+K?qEJmZbiW-zu+!bYY)>u(u z3DFnc(7>9|Ogf$tqD^RKv7qHj!|!qi}LxfO9>%4B_wji zxtvmMrFmljnp;IFCd#r}%B!6sczXE8R$Qi71XmzG(~fjQH%EEBSS@DW&Ph$1Z?CvV zSSlRGQJS8&a^8rtnfGePd49-A4Gy-@im+2mn)y(2^J=k`3@*&9N=70TjXfP}CL^KE z=J8R|&KtXl#kzuFLfg@-l4Vx`%Ovv6SbR-pz3rZ}NjA(^I z&Nv9$LDGH-rv`7v{V2*wtU(3mMEpNm#-Z>@YfWH1;)ldimj^qS=4Hjv?UTx5B2upu%D=WN?)QHwDGGWpwN z5RJ8|ro>_R-)4s0uxE0B0&-||$!&JhW0clvIM*m8X+D~Dvp7f}ZB~hc_X#T$2>2Qb zZ~=Z(caQId>X?NLPvI^VlQO0cJ`!c+r=g#fwPI*A7&^SLXxwr+)wop-S3>)Es}a#; zc9$p_8|HYD9I&K?DKRU@rbYA*T>Q}|*XtW=DI9BR95Ynz>q6x4h&Lu@#odv3CMufN zE2SmXppX@=5^xo6z~AAMy8EeR(663+jBdkDpL*)@qSaNm?tcetGlUO0^bfTWj~VGO zizvhvBav{=1A-GGx-MV}6uW;q=50N`f-6G%)+`^I@z&R~sFN$O&3I9{f zUAglr;$2ZZ^#FB^fFHr{`Jw-JR3Z-JPv None: + mocker.patch( + "requests.get", + side_effect=ConnectionError( + "Failed to resolve 'api.github.com' ([Errno -3] Temporary failure in name resolution)" + ), + ) + modal = AboutModal("/tmp") + qtbot.addWidget(modal) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 9ae15726b..b624356e0 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Callable diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 05aee1504..64126b8a5 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from tagstudio.core.library.alchemy.library import Library diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 412cd56fe..1d28f33db 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import os diff --git a/tests/qt/test_flow_widget.py b/tests/qt/test_flow_widget.py index 115ded7a3..97393416e 100644 --- a/tests/qt/test_flow_widget.py +++ b/tests/qt/test_flow_widget.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtCore import QRect diff --git a/tests/qt/test_folders_to_tags.py b/tests/qt/test_folders_to_tags.py index eebee9994..7e48fc591 100644 --- a/tests/qt/test_folders_to_tags.py +++ b/tests/qt/test_folders_to_tags.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from tagstudio.core.library.alchemy.library import Library diff --git a/tests/qt/test_global_settings.py b/tests/qt/test_global_settings.py index 95d6b5f6a..63eb51953 100644 --- a/tests/qt/test_global_settings.py +++ b/tests/qt/test_global_settings.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path diff --git a/tests/qt/test_item_thumb.py b/tests/qt/test_item_thumb.py index f022f9767..d5c1e904a 100644 --- a/tests/qt/test_item_thumb.py +++ b/tests/qt/test_item_thumb.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import pytest diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 7e0da81f7..c585861b7 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from PySide6.QtCore import Qt @@ -63,23 +62,23 @@ def test_add_field_to_selection_multiple_refreshes(qt_driver: QtDriver, library: panel.set_selection(qt_driver.selected, update_preview=False) selected_entries = list(library.get_entries_full([1, 2])) - existing_field_keys = {field.type_key for entry in selected_entries for field in entry.fields} - field_type = next( - value_type - for value_type in library.field_types.values() - if value_type.key not in existing_field_keys + existing_field_names = {field.name for entry in selected_entries for field in entry.fields} + field_template = next( + template + for template in library.field_templates + if template.name not in existing_field_names ) - item = QListWidgetItem(f"{field_type.name} ({field_type.type.value})") - item.setData(Qt.ItemDataRole.UserRole, field_type.key) + item = QListWidgetItem(field_template.name) + item.setData(Qt.ItemDataRole.UserRole, field_template) panel._add_field_to_selected([item]) refreshed_entries = list(library.get_entries_full([1, 2])) assert all( - any(field.type_key == field_type.key for field in entry.fields) for entry in refreshed_entries + any(field.name == field_template.name for field in entry.fields) for entry in refreshed_entries ) assert all( - any(field.type_key == field_type.key for field in entry.fields) + any(field.name == field_template.name for field in entry.fields) for entry in panel.field_containers_widget.cached_entries ) diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py index 3cf042aa7..37bd38324 100644 --- a/tests/qt/test_qt_driver.py +++ b/tests/qt/test_qt_driver.py @@ -1,6 +1,6 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.utils.types import unwrap diff --git a/tests/qt/test_resource_manager.py b/tests/qt/test_resource_manager.py index bb9ae72f8..5a6d72df5 100644 --- a/tests/qt/test_resource_manager.py +++ b/tests/qt/test_resource_manager.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import structlog diff --git a/tests/qt/test_tag_panel.py b/tests/qt/test_tag_panel.py index 0d813da69..d5c705646 100644 --- a/tests/qt/test_tag_panel.py +++ b/tests/qt/test_tag_panel.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pytestqt.qtbot import QtBot diff --git a/tests/qt/test_tag_search_panel.py b/tests/qt/test_tag_search_panel.py index bc455685e..05bd68a9c 100644 --- a/tests/qt/test_tag_search_panel.py +++ b/tests/qt/test_tag_search_panel.py @@ -1,6 +1,7 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + from PySide6.QtCore import SIGNAL from pytestqt.qtbot import QtBot @@ -28,7 +29,7 @@ def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver # Set the widget tags = library.tags panel.set_tag_widget(tags[0], 0) - tag_widget: TagWidget = panel.scroll_layout.itemAt(0).widget() + tag_widget: TagWidget = panel.scroll_layout.itemAt(0).widget() # pyright: ignore[reportAssignmentType] should_replace_actions = { tag_widget: ["on_edit()", "on_remove()"], diff --git a/tests/qt/test_theme_system.py b/tests/qt/test_theme_system.py new file mode 100644 index 000000000..ab2eecf3b --- /dev/null +++ b/tests/qt/test_theme_system.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +"""Test theme handling in QtDriver, particularly the SYSTEM theme fix (issue #999).""" + +from unittest.mock import Mock + +import pytest +from PySide6.QtCore import Qt + +from tagstudio.qt.global_settings import Theme + + +@pytest.mark.parametrize( + "theme,expected_call", + [ + (Theme.DARK, Qt.ColorScheme.Dark), + (Theme.LIGHT, Qt.ColorScheme.Light), + (Theme.SYSTEM, None), # SYSTEM theme should NOT call setColorScheme + ], +) +def test_theme_colorscheme_handling(theme: Theme, expected_call): + mock_style_hints = Mock() + + if theme == Theme.DARK: + mock_style_hints.setColorScheme(Qt.ColorScheme.Dark) + elif theme == Theme.LIGHT: + mock_style_hints.setColorScheme(Qt.ColorScheme.Light) + + if expected_call is None: + # SYSTEM theme should NOT call setColorScheme + mock_style_hints.setColorScheme.assert_not_called() + else: + mock_style_hints.setColorScheme.assert_called_once_with(expected_call) diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py index 2a1c7c960..87ef3574c 100644 --- a/tests/test_db_migrations.py +++ b/tests/test_db_migrations.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import shutil @@ -27,6 +26,11 @@ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")), str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")), str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_101")), + # str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_102")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_103")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_200")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_201")), ], ) def test_library_migrations(path: str): @@ -46,9 +50,9 @@ def test_library_migrations(path: str): try: status = library.open_library(library_dir=temp_path) library.close() - shutil.rmtree(temp_path) assert status.success except Exception as e: library.close() - shutil.rmtree(temp_path) raise (e) + finally: + shutil.rmtree(temp_path) diff --git a/tests/test_driver.py b/tests/test_driver.py index 903cf1aae..5ca04b046 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path diff --git a/tests/test_json_migration.py b/tests/test_json_migration.py index 25ad8fc1b..3c3c15db3 100644 --- a/tests/test_json_migration.py +++ b/tests/test_json_migration.py @@ -1,12 +1,10 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from pathlib import Path from time import time -from tagstudio.core.enums import LibraryPrefs from tagstudio.qt.mixed.migration_modal import JsonMigrationModal CWD = Path(__file__) @@ -43,10 +41,4 @@ def test_json_migration(): assert modal.check_color_parity() # Extension Filter List ==================================================== - # Count - assert len(modal.json_lib.ext_list) == len(modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)) - # List Type - assert modal.check_ext_type() - # No Leading Dot - for ext in modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST): # pyright: ignore[reportUnknownVariableType] - assert ext[0] != "." + modal.assert_ignore_parity() diff --git a/tests/test_library.py b/tests/test_library.py index 447344512..17f0d990d 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from collections.abc import Callable @@ -10,10 +9,9 @@ import pytest import structlog -from tagstudio.core.enums import DefaultEnum, LibraryPrefs from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.fields import ( - FieldID, # pyright: ignore[reportPrivateUsage] + DatetimeField, TextField, ) from tagstudio.core.library.alchemy.library import Library @@ -82,12 +80,12 @@ def test_library_add_file(library: Library): entry = Entry( path=Path("bar.txt"), folder=unwrap(library.folder), - fields=library.default_fields, + fields=[TextField(name="Title", value="I'm a Test Title")], ) - assert not library.has_path_entry(entry.path) + assert not library.has_entry_with_path(entry.path) assert library.add_entries([entry]) - assert library.has_path_entry(entry.path) + assert library.has_entry_with_path(entry.path) def test_create_tag(library: Library, generate_tag: Callable[..., Tag]): @@ -131,10 +129,10 @@ def test_library_search(library: Library, entry_full: Entry): def test_tag_search(library: Library): tag = library.tags[0] - assert library.search_tags(tag.name.lower()) - assert library.search_tags(tag.name.upper()) - assert library.search_tags(tag.name[2:-2]) - assert library.search_tags(tag.name * 2) == [set(), set()] + assert library.search_tags(tag.name.lower())[0] + assert library.search_tags(tag.name.upper())[0] + assert library.search_tags(tag.name[2:-2])[0] + assert library.search_tags(tag.name * 2) == ([], []) def test_get_entry(library: Library, entry_min: Entry): @@ -199,11 +197,6 @@ def test_search_library_case_insensitive(library: Library): assert results[0] == entry.id -def test_preferences(library: Library): - for pref in LibraryPrefs: - assert library.prefs(pref) == pref.default - - def test_remove_entry_field(library: Library, entry_full: Entry): title_field = entry_full.text_fields[0] @@ -213,13 +206,13 @@ def test_remove_entry_field(library: Library, entry_full: Entry): assert not entry.text_fields -def test_remove_field_entry_with_multiple_field(library: Library, entry_full: Entry): +def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_full: Entry): # Given title_field = entry_full.text_fields[0] # When # add identical field - assert library.add_field_to_entry(entry_full.id, field_id=title_field.type_key) + assert library.add_field_to_entries(entry_full.id, field=title_field) # remove entry field library.remove_entry_field(title_field, [entry_full.id]) @@ -232,30 +225,23 @@ def test_remove_field_entry_with_multiple_field(library: Library, entry_full: En def test_update_entry_field(library: Library, entry_full: Entry): title_field = entry_full.text_fields[0] - library.update_entry_field( - entry_full.id, - title_field, - "new value", - ) + library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) entry = next(library.all_entries(with_joins=True)) assert entry.text_fields[0].value == "new value" -def test_update_entry_with_multiple_identical_fields(library: Library, entry_full: Entry): +def test_update_entry_with_multiple_identical_text_fields(library: Library, entry_full: Entry): # Given title_field = entry_full.text_fields[0] # When # add identical field - library.add_field_to_entry(entry_full.id, field_id=title_field.type_key) + empty_title = TextField(name="Title", value="") + library.add_field_to_entries(entry_full.id, field=empty_title) # update one of the fields - library.update_entry_field( - entry_full.id, - title_field, - "new value", - ) + library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) # Then only one should be updated entry = next(library.all_entries(with_joins=True)) @@ -263,37 +249,70 @@ def test_update_entry_with_multiple_identical_fields(library: Library, entry_ful assert entry.text_fields[1].value == "new value" -def test_mirror_entry_fields(library: Library, entry_full: Entry): - # new entry - target_entry = Entry( +def test_mirror_entry_fields(library: Library): + # Create and add entries with fields + entry_a = Entry( folder=unwrap(library.folder), - path=Path("xxx"), + path=Path("title_and_date.txt"), fields=[ - TextField( - type_key=FieldID.NOTES.name, - value="notes", - position=0, - ) + TextField(name="Title", value="I'm a Test Title"), + DatetimeField(name="Date", value="2026-05-07 12:59:24"), ], ) - - # insert new entry and get id - entry_id = library.add_entries([target_entry])[0] - - # get new entry from library - new_entry = unwrap(library.get_entry_full(entry_id)) - - # mirror fields onto new entry - library.mirror_entry_fields(new_entry, entry_full) - - # get new entry from library again - entry = unwrap(library.get_entry_full(entry_id)) - - # make sure fields are there after getting it from the library again - assert len(entry.fields) == 2 - assert {x.type_key for x in entry.fields} == { - FieldID.TITLE.name, - FieldID.NOTES.name, + entry_b = Entry( + folder=unwrap(library.folder), + path=Path("notes.txt"), + fields=[ + TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True), + TextField(name="Title", value="I'm a Test Title"), + ], + ) + entry_c = Entry( + folder=unwrap(library.folder), + path=Path("date_published.txt"), + fields=[ + DatetimeField(name="Date Published", value="2000-01-01 12:00:00"), + ], + ) + entry_a_id, entry_b_id, entry_c_id = library.add_entries([entry_a, entry_b, entry_c]) + + # Retrieve from library + entry_a_ = unwrap(library.get_entry_full(entry_a_id)) + entry_b_ = unwrap(library.get_entry_full(entry_b_id)) + entry_c_ = unwrap(library.get_entry_full(entry_c_id)) + + # Sanity check for initial fields + assert entry_a_.fields[0].name == "Title" + assert entry_a_.fields[1].name == "Date" + assert entry_b_.fields[0].name == "Notes" + assert entry_c_.fields[0].name == "Date Published" + assert len(entry_a_.fields) == 2 + assert len(entry_b_.fields) == 2 + assert len(entry_c_.fields) == 1 + + # Mirror fields between entries + library.mirror_entry_fields([entry_b_, entry_a_, entry_c_]) + + # Retrieve from library, again + entry_a_mirrored = unwrap(library.get_entry_full(entry_a_id)) + entry_b_mirrored = unwrap(library.get_entry_full(entry_b_id)) + entry_c_mirrored = unwrap(library.get_entry_full(entry_c_id)) + + for entry in [entry_a_mirrored, entry_b_mirrored, entry_c_mirrored]: + logger.info( + "[Library][mirror_fields]", entry_id=entry.id, field_count_after=len(entry.fields) + ) + + # Assert presence of all fields on all entries + assert len(entry_a_mirrored.fields) == 4 + assert len(entry_b_mirrored.fields) == 4 + assert len(entry_c_mirrored.fields) == 4 + + assert {(type(x), x.name) for x in entry_a_mirrored.fields} == { + (TextField, "Title"), + (TextField, "Notes"), + (DatetimeField, "Date"), + (DatetimeField, "Date Published"), } @@ -304,32 +323,32 @@ def test_merge_entries(library: Library): tag_1: Tag = unwrap(library.add_tag(Tag(id=1011, name="tag_1"))) tag_2: Tag = unwrap(library.add_tag(Tag(id=1012, name="tag_2"))) - a = Entry( + entry_a = Entry( folder=folder, path=Path("a"), fields=[ - TextField(type_key=FieldID.AUTHOR.name, value="Author McAuthorson", position=0), - TextField(type_key=FieldID.DESCRIPTION.name, value="test description", position=2), + TextField(name="Author", value="Author McAuthorson"), + TextField(name="Description", value="test description", is_multiline=True), ], ) - b = Entry( + entry_b = Entry( folder=folder, path=Path("b"), - fields=[TextField(type_key=FieldID.NOTES.name, value="test note", position=1)], + fields=[TextField(name="Notes", value="test note", is_multiline=True)], ) - ids = library.add_entries([a, b]) + entry_a_id, entry_b_id = library.add_entries([entry_a, entry_b]) - library.add_tags_to_entries(ids[0], [tag_0.id, tag_2.id]) - library.add_tags_to_entries(ids[1], [tag_1.id]) + library.add_tags_to_entries(entry_a_id, [tag_0.id, tag_2.id]) + library.add_tags_to_entries(entry_b_id, [tag_1.id]) - entry_a: Entry = unwrap(library.get_entry_full(ids[0])) - entry_b: Entry = unwrap(library.get_entry_full(ids[1])) + entry_a_: Entry = unwrap(library.get_entry_full(entry_a_id)) + entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id)) - assert library.merge_entries(entry_a, entry_b) - assert not library.has_path_entry(Path("a")) - assert library.has_path_entry(Path("b")) + 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")) - entry_b_merged = unwrap(library.get_entry_full(ids[1])) + entry_b_merged = unwrap(library.get_entry_full(entry_b_id)) fields = [field.value for field in entry_b_merged.fields] assert "Author McAuthorson" in fields @@ -366,51 +385,6 @@ def test_search_entry_id(library: Library, query_name: int, has_result: bool): assert (result is not None) == has_result -def test_update_field_order(library: Library, entry_full: Entry): - # Given - title_field = entry_full.text_fields[0] - - # When add two more fields - library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="first") - library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="second") - - # remove the one on first position - assert title_field.position == 0 - library.remove_entry_field(title_field, [entry_full.id]) - - # recalculate the positions - library.update_field_position( - type(title_field), - title_field.type_key, - entry_full.id, - ) - - # Then - entry = next(library.all_entries(with_joins=True)) - assert entry.text_fields[0].position == 0 - assert entry.text_fields[0].value == "first" - assert entry.text_fields[1].position == 1 - assert entry.text_fields[1].value == "second" - - -def test_library_prefs_multiple_identical_vals(): - # check the preferences are inherited from DefaultEnum - assert issubclass(LibraryPrefs, DefaultEnum) - - # create custom settings with identical values - class TestPrefs(DefaultEnum): - FOO = 1 - BAR = 1 - - assert TestPrefs.FOO.default == 1 - assert TestPrefs.BAR.default == 1 - assert TestPrefs.BAR.name == "BAR" - - # accessing .value should raise exception - with pytest.raises(AttributeError): - assert TestPrefs.BAR.value - - def test_path_search_ilike(library: Library): results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) assert results.total_count == 1 diff --git a/tests/test_search.py b/tests/test_search.py index 79812dfa1..ee36af1e9 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import pytest diff --git a/tests/test_translations.py b/tests/test_translations.py index f01be2c63..8d0533f37 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only import string