diff --git a/src/tribler/core/components/knowledge/db/knowledge_db.py b/src/tribler/core/components/knowledge/db/knowledge_db.py index 65fd233e4f7..9067d55828d 100644 --- a/src/tribler/core/components/knowledge/db/knowledge_db.py +++ b/src/tribler/core/components/knowledge/db/knowledge_db.py @@ -2,7 +2,7 @@ import logging from dataclasses import dataclass from enum import IntEnum -from typing import Callable, Iterable, List, Optional, Set +from typing import Callable, Iterable, List, Optional, Set, Dict from pony import orm from pony.orm.core import Entity diff --git a/src/tribler/core/components/metadata_store/remote_query_community/tests/test_remote_search_by_tags.py b/src/tribler/core/components/metadata_store/remote_query_community/tests/test_remote_search_by_tags.py index 0bf477b3c08..b12e258eacd 100644 --- a/src/tribler/core/components/metadata_store/remote_query_community/tests/test_remote_search_by_tags.py +++ b/src/tribler/core/components/metadata_store/remote_query_community/tests/test_remote_search_by_tags.py @@ -58,7 +58,7 @@ def rqc(self) -> RemoteQueryCommunity: @patch.object(RemoteQueryCommunity, 'knowledge_db', new=PropertyMock(return_value=None), create=True) async def test_search_for_tags_no_db(self): - # test that in case of missed `tags_db`, function `search_for_tags` returns None + # test that in case of missed `knowledge_db`, function `search_for_tags` returns None assert self.rqc.search_for_tags(tags=['tag']) is None @patch.object(KnowledgeDatabase, 'get_subjects_intersection') diff --git a/src/tribler/core/components/metadata_store/restapi/channels_endpoint.py b/src/tribler/core/components/metadata_store/restapi/channels_endpoint.py index 3f9fbd2c094..48463c1af42 100644 --- a/src/tribler/core/components/metadata_store/restapi/channels_endpoint.py +++ b/src/tribler/core/components/metadata_store/restapi/channels_endpoint.py @@ -196,7 +196,7 @@ async def get_channel_contents(self, request): contents_list.append(entry.to_simple_dict()) total = self.mds.get_total_count(**sanitized) if include_total else None self.add_download_progress_to_metadata_list(contents_list) - self.add_tags_to_metadata_list(contents_list, hide_xxx=sanitized["hide_xxx"]) + self.add_statements_to_metadata_list(contents_list, hide_xxx=sanitized["hide_xxx"]) response_dict = { "results": contents_list, "first": sanitized['first'], @@ -504,7 +504,7 @@ async def get_popular_torrents_channel(self, request): contents_list.append(entry.to_simple_dict()) self.add_download_progress_to_metadata_list(contents_list) - self.add_tags_to_metadata_list(contents_list, hide_xxx=sanitized["hide_xxx"]) + self.add_statements_to_metadata_list(contents_list, hide_xxx=sanitized["hide_xxx"]) response_dict = { "results": contents_list, "first": sanitized['first'], diff --git a/src/tribler/core/components/metadata_store/restapi/metadata_endpoint_base.py b/src/tribler/core/components/metadata_store/restapi/metadata_endpoint_base.py index ff1b04c2a16..9d4c14bd031 100644 --- a/src/tribler/core/components/metadata_store/restapi/metadata_endpoint_base.py +++ b/src/tribler/core/components/metadata_store/restapi/metadata_endpoint_base.py @@ -1,3 +1,4 @@ +from dataclasses import asdict from typing import Optional from pony.orm import db_session @@ -38,11 +39,11 @@ class MetadataEndpointBase(RESTEndpoint): - def __init__(self, metadata_store: MetadataStore, *args, tags_db: KnowledgeDatabase = None, + def __init__(self, metadata_store: MetadataStore, *args, knowledge_db: KnowledgeDatabase = None, tag_rules_processor: KnowledgeRulesProcessor = None, **kwargs): super().__init__(*args, **kwargs) self.mds = metadata_store - self.tags_db: Optional[KnowledgeDatabase] = tags_db + self.knowledge_db: Optional[KnowledgeDatabase] = knowledge_db self.tag_rules_processor: Optional[KnowledgeRulesProcessor] = tag_rules_processor @classmethod @@ -84,13 +85,15 @@ def extract_tags(self, entry): self._logger.info(f'Generated {generated} tags for {hexlify(entry.infohash)}') @db_session - def add_tags_to_metadata_list(self, contents_list, hide_xxx=False): - if self.tags_db is None: - self._logger.error(f'Cannot add tags to metadata list: tags_db is not set in {self.__class__.__name__}') + def add_statements_to_metadata_list(self, contents_list, hide_xxx=False): + if self.knowledge_db is None: + self._logger.error(f'Cannot add statements to metadata list: ' + f'knowledge_db is not set in {self.__class__.__name__}') return for torrent in contents_list: if torrent['type'] == REGULAR_TORRENT: - tags = self.tags_db.get_objects(torrent["infohash"], predicate=ResourceType.TAG) + statements = [asdict(stmt) for stmt in self.knowledge_db.get_statements(torrent["infohash"])] if hide_xxx: - tags = [tag.lower() for tag in tags if not default_xxx_filter.isXXX(tag, isFilename=False)] - torrent["tags"] = tags + statements = [stmt for stmt in statements if not default_xxx_filter.isXXX(stmt["object"], + isFilename=False)] + torrent["statements"] = statements diff --git a/src/tribler/core/components/metadata_store/restapi/search_endpoint.py b/src/tribler/core/components/metadata_store/restapi/search_endpoint.py index 8edba7670a9..e0710d53db0 100644 --- a/src/tribler/core/components/metadata_store/restapi/search_endpoint.py +++ b/src/tribler/core/components/metadata_store/restapi/search_endpoint.py @@ -45,8 +45,8 @@ def build_snippets(self, search_results: List[Dict]) -> List[Dict]: content_to_torrents: Dict[str, list] = defaultdict(list) for search_result in search_results: with db_session: - content_items: List[str] = self.tags_db.get_objects(search_result["infohash"], - predicate=ResourceType.TITLE) + content_items: List[str] = self.knowledge_db.get_objects(search_result["infohash"], + predicate=ResourceType.TITLE) if content_items: for content_id in content_items: content_to_torrents[content_id].append(search_result) @@ -131,8 +131,8 @@ def search_db(): try: with db_session: if tags: - infohash_set = self.tags_db.get_subjects_intersection(set(tags), predicate=ResourceType.TAG, - case_sensitive=False) + infohash_set = self.knowledge_db.get_subjects_intersection(set(tags), predicate=ResourceType.TAG, + case_sensitive=False) if infohash_set: sanitized['infohash_set'] = {bytes.fromhex(s) for s in infohash_set} @@ -141,7 +141,7 @@ def search_db(): self._logger.exception("Error while performing DB search: %s: %s", type(e).__name__, e) return RESTResponse(status=HTTP_BAD_REQUEST) - self.add_tags_to_metadata_list(search_results, hide_xxx=sanitized["hide_xxx"]) + self.add_statements_to_metadata_list(search_results, hide_xxx=sanitized["hide_xxx"]) if sanitized["first"] == 1: # Only show a snippet on top search_results = self.build_snippets(search_results) diff --git a/src/tribler/core/components/metadata_store/restapi/tests/test_channels_endpoint.py b/src/tribler/core/components/metadata_store/restapi/tests/test_channels_endpoint.py index f4b2afa1d6d..8e44849e1b1 100644 --- a/src/tribler/core/components/metadata_store/restapi/tests/test_channels_endpoint.py +++ b/src/tribler/core/components/metadata_store/restapi/tests/test_channels_endpoint.py @@ -13,6 +13,7 @@ import pytest from tribler.core.components.gigachannel.community.gigachannel_community import NoChannelSourcesException +from tribler.core.components.knowledge.db.knowledge_db import ResourceType from tribler.core.components.libtorrent.torrentdef import TorrentDef from tribler.core.components.metadata_store.category_filter.family_filter import default_xxx_filter from tribler.core.components.metadata_store.db.orm_bindings.channel_node import NEW @@ -49,7 +50,7 @@ def return_exc(*args, **kwargs): mock_gigachannel_community.remote_select_channel_contents = return_exc ep_args = [mock_dlmgr, mock_gigachannel_manager, mock_gigachannel_community, metadata_store] - ep_kwargs = {'tags_db': knowledge_db} + ep_kwargs = {'knowledge_db': knowledge_db} collections_endpoint = ChannelsEndpoint(*ep_args, **ep_kwargs) channels_endpoint = ChannelsEndpoint(*ep_args, **ep_kwargs) @@ -713,7 +714,7 @@ async def test_get_my_channel_tags(metadata_store, mock_dlmgr_get_download, my_c assert len(json_dict['results']) == 9 for item in json_dict['results']: - assert len(item["tags"]) >= 2 + assert len(item["statements"]) >= 2 async def test_get_my_channel_tags_xxx(metadata_store, knowledge_db, mock_dlmgr_get_download, my_channel, @@ -739,4 +740,6 @@ async def test_get_my_channel_tags_xxx(metadata_store, knowledge_db, mock_dlmgr_ ) assert len(json_dict['results']) == 1 - assert len(json_dict['results'][0]["tags"]) == 1 + print(json_dict) + tag_statements = [s for s in json_dict["results"][0]["statements"] if s["predicate"] == ResourceType.TAG] + assert len(tag_statements) == 1 diff --git a/src/tribler/core/components/metadata_store/restapi/tests/test_metadata_endpoint.py b/src/tribler/core/components/metadata_store/restapi/tests/test_metadata_endpoint.py index 28d1cd56a6a..85394f4c33d 100644 --- a/src/tribler/core/components/metadata_store/restapi/tests/test_metadata_endpoint.py +++ b/src/tribler/core/components/metadata_store/restapi/tests/test_metadata_endpoint.py @@ -252,7 +252,7 @@ def test_extract_tags(): # see: https://github.com/Tribler/tribler/issues/6986 mds_endpoint = MetadataEndpointBase( MagicMock(), - tags_db=MagicMock(), + knowledge_db=MagicMock(), tag_rules_processor=MagicMock( version=1 ) diff --git a/src/tribler/core/components/metadata_store/restapi/tests/test_search_endpoint.py b/src/tribler/core/components/metadata_store/restapi/tests/test_search_endpoint.py index 08375079a9c..f0cc69f5e3a 100644 --- a/src/tribler/core/components/metadata_store/restapi/tests/test_search_endpoint.py +++ b/src/tribler/core/components/metadata_store/restapi/tests/test_search_endpoint.py @@ -34,7 +34,7 @@ def needle_in_haystack_mds(metadata_store): @pytest.fixture def rest_api(loop, needle_in_haystack_mds, aiohttp_client, knowledge_db): - channels_endpoint = SearchEndpoint(needle_in_haystack_mds, tags_db=knowledge_db) + channels_endpoint = SearchEndpoint(needle_in_haystack_mds, knowledge_db=knowledge_db) app = Application() app.add_subapp('/search', channels_endpoint.app) return loop.run_until_complete(aiohttp_client(app)) diff --git a/src/tribler/core/components/metadata_store/utils.py b/src/tribler/core/components/metadata_store/utils.py index 31191d3f315..74483b34993 100644 --- a/src/tribler/core/components/metadata_store/utils.py +++ b/src/tribler/core/components/metadata_store/utils.py @@ -57,13 +57,13 @@ def tag_torrent(infohash, tags_db, tags=None, suggested_tags=None): if tag not in suggested_tags: suggested_tags.append(tag) - def _add_operation(_tag, _op, _key): - operation = StatementOperation(subject_type=ResourceType.TORRENT, subject=infohash, predicate=ResourceType.TAG, - object=_tag, operation=_op, clock=0, creator_public_key=_key.pub().key_to_bin()) + def _add_operation(_obj, _op, _key, _predicate=ResourceType.TAG): + operation = StatementOperation(subject_type=ResourceType.TORRENT, subject=infohash, predicate=_predicate, + object=_obj, operation=_op, clock=0, creator_public_key=_key.pub().key_to_bin()) operation.clock = tags_db.get_clock(operation) + 1 tags_db.add_operation(operation, b"") - # Give each torrent some tags + # Give the torrent some tags for tag in tags: for key in [random_key_1, random_key_2]: # Each tag should be proposed by two unique users _add_operation(tag, Operation.ADD, key) @@ -73,6 +73,17 @@ def _add_operation(_tag, _op, _key): _add_operation(tag, Operation.ADD, random_key_3) _add_operation(tag, Operation.REMOVE, random_key_2) + # Give the torrent some simple attributes + random_title = generate_title(2) + random_year = f"{random.randint(1990, 2040)}" + random_description = generate_title(5) + random_lang = random.choice(["english", "russian", "dutch", "klingon", "valyerian"]) + for key in [random_key_1, random_key_2]: # Each statement should be proposed by two unique users + _add_operation(random_title, Operation.ADD, key, _predicate=ResourceType.TITLE) + _add_operation(random_year, Operation.ADD, key, _predicate=ResourceType.DATE) + _add_operation(random_description, Operation.ADD, key, _predicate=ResourceType.DESCRIPTION) + _add_operation(random_lang, Operation.ADD, key, _predicate=ResourceType.LANGUAGE) + @db_session def generate_torrent(metadata_store, tags_db, parent): diff --git a/src/tribler/core/components/restapi/restapi_component.py b/src/tribler/core/components/restapi/restapi_component.py index e4e6300c9e6..fab9b55ac44 100644 --- a/src/tribler/core/components/restapi/restapi_component.py +++ b/src/tribler/core/components/restapi/restapi_component.py @@ -110,18 +110,18 @@ async def run(self): self.maybe_add('/libtorrent', LibTorrentEndpoint, libtorrent_component.download_manager) self.maybe_add('/torrentinfo', TorrentInfoEndpoint, libtorrent_component.download_manager) self.maybe_add('/metadata', MetadataEndpoint, torrent_checker, metadata_store_component.mds, - tags_db=knowledge_component.knowledge_db, + knowledge_db=knowledge_component.knowledge_db, tag_rules_processor=knowledge_component.rules_processor) self.maybe_add('/channels', ChannelsEndpoint, libtorrent_component.download_manager, gigachannel_manager, gigachannel_component.community, metadata_store_component.mds, - tags_db=knowledge_component.knowledge_db, + knowledge_db=knowledge_component.knowledge_db, tag_rules_processor=knowledge_component.rules_processor) self.maybe_add('/collections', ChannelsEndpoint, libtorrent_component.download_manager, gigachannel_manager, gigachannel_component.community, metadata_store_component.mds, - tags_db=knowledge_component.knowledge_db, + knowledge_db=knowledge_component.knowledge_db, tag_rules_processor=knowledge_component.rules_processor) self.maybe_add('/search', SearchEndpoint, metadata_store_component.mds, - tags_db=knowledge_component.knowledge_db) + knowledge_db=knowledge_component.knowledge_db) self.maybe_add('/remote_query', RemoteQueryEndpoint, gigachannel_component.community, metadata_store_component.mds) self.maybe_add('/knowledge', KnowledgeEndpoint, db=knowledge_component.knowledge_db, diff --git a/src/tribler/gui/dialogs/addtagsdialog.py b/src/tribler/gui/dialogs/addtagsdialog.py deleted file mode 100644 index 3c6f2c2694b..00000000000 --- a/src/tribler/gui/dialogs/addtagsdialog.py +++ /dev/null @@ -1,92 +0,0 @@ -from typing import Dict, Optional, List - -from PyQt5 import uic -from PyQt5.QtCore import QModelIndex, QPoint, pyqtSignal -from PyQt5.QtWidgets import QSizePolicy, QWidget - -from tribler.core.components.knowledge.db.knowledge_db import ResourceType -from tribler.core.components.knowledge.knowledge_constants import MAX_RESOURCE_LENGTH, MIN_RESOURCE_LENGTH - -from tribler.gui.defs import TAG_HORIZONTAL_MARGIN -from tribler.gui.dialogs.dialogcontainer import DialogContainer -from tribler.gui.tribler_request_manager import TriblerNetworkRequest -from tribler.gui.utilities import connect, get_ui_file_path, tr -from tribler.gui.widgets.tagbutton import TagButton - - -class AddTagsDialog(DialogContainer): - """ - This dialog enables a user to add new tags to/remove existing tags from content. - """ - - save_button_clicked = pyqtSignal(QModelIndex, list) - suggestions_loaded = pyqtSignal() - - def __init__(self, parent: QWidget, infohash: str) -> None: - DialogContainer.__init__(self, parent, left_right_margin=400) - self.index: Optional[QModelIndex] = None - self.infohash = infohash - - uic.loadUi(get_ui_file_path('add_tags_dialog.ui'), self.dialog_widget) - - self.dialog_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - connect(self.dialog_widget.close_button.clicked, self.close_dialog) - connect(self.dialog_widget.save_button.clicked, self.on_save_tags_button_clicked) - connect(self.dialog_widget.edit_tags_input.enter_pressed, lambda: self.on_save_tags_button_clicked(None)) - connect(self.dialog_widget.edit_tags_input.escape_pressed, self.close_dialog) - - self.dialog_widget.edit_tags_input.setFocus() - self.dialog_widget.error_text_label.hide() - self.dialog_widget.suggestions_container.hide() - - # Fetch suggestions - TriblerNetworkRequest(f"knowledge/{infohash}/tag_suggestions", self.on_received_tag_suggestions) - - self.update_window() - - def on_save_tags_button_clicked(self, _) -> None: - statements: List[Dict] = [] - - # Sanity check the entered tags - entered_tags = self.dialog_widget.edit_tags_input.get_entered_tags() - for tag in entered_tags: - if len(tag) < MIN_RESOURCE_LENGTH or len(tag) > MAX_RESOURCE_LENGTH: - self.dialog_widget.error_text_label.setText( - tr( - "Each tag should be at least %d characters and can be at most %d characters." - % (MIN_RESOURCE_LENGTH, MAX_RESOURCE_LENGTH) - ) - ) - self.dialog_widget.error_text_label.setHidden(False) - return - - statements.append({ - "predicate": ResourceType.TAG, - "object": tag, - }) - - self.save_button_clicked.emit(self.index, statements) - - def on_received_tag_suggestions(self, data: Dict) -> None: - self.suggestions_loaded.emit() - if data["suggestions"]: - self.dialog_widget.suggestions_container.show() - - cur_x = 0 - - for suggestion in data["suggestions"]: - tag_button = TagButton(self.dialog_widget.suggestions, suggestion) - connect(tag_button.clicked, lambda _, btn=tag_button: self.clicked_suggestion(btn)) - tag_button.move(QPoint(cur_x, tag_button.y())) - cur_x += tag_button.width() + TAG_HORIZONTAL_MARGIN - tag_button.show() - - self.update_window() - - def clicked_suggestion(self, tag_button: TagButton) -> None: - self.dialog_widget.edit_tags_input.add_tag(tag_button.text()) - tag_button.setParent(None) - - def update_window(self) -> None: - self.dialog_widget.adjustSize() - self.on_main_window_resize() diff --git a/src/tribler/gui/dialogs/editmetadatadialog.py b/src/tribler/gui/dialogs/editmetadatadialog.py new file mode 100644 index 00000000000..1b375b38943 --- /dev/null +++ b/src/tribler/gui/dialogs/editmetadatadialog.py @@ -0,0 +1,161 @@ +import json +from typing import Dict, List + +from PyQt5 import uic +from PyQt5.QtCore import QModelIndex, QPoint, pyqtSignal, Qt +from PyQt5.QtWidgets import QComboBox, QSizePolicy, QWidget + +from tribler.core.components.knowledge.db.knowledge_db import ResourceType +from tribler.core.components.knowledge.knowledge_constants import MAX_RESOURCE_LENGTH, MIN_RESOURCE_LENGTH + +from tribler.gui.defs import TAG_HORIZONTAL_MARGIN +from tribler.gui.dialogs.dialogcontainer import DialogContainer +from tribler.gui.tribler_request_manager import TriblerNetworkRequest +from tribler.gui.utilities import connect, get_ui_file_path, tr, get_objects_with_predicate +from tribler.gui.widgets.tagbutton import TagButton + + +METADATA_TABLE_PREDICATES = [ResourceType.TITLE, ResourceType.DESCRIPTION, ResourceType.DATE, ResourceType.LANGUAGE] + + +class EditMetadataDialog(DialogContainer): + """ + This dialog enables a user to edit metadata associated with particular content. + """ + + save_button_clicked = pyqtSignal(QModelIndex, list) + suggestions_loaded = pyqtSignal() + + def __init__(self, parent: QWidget, index: QModelIndex) -> None: + DialogContainer.__init__(self, parent, left_right_margin=400) + self.index: QModelIndex = index + self.data_item = self.index.model().data_items[self.index.row()] + self.infohash = self.data_item["infohash"] + + uic.loadUi(get_ui_file_path('edit_metadata_dialog.ui'), self.dialog_widget) + + self.dialog_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + connect(self.dialog_widget.close_button.clicked, self.close_dialog) + connect(self.dialog_widget.save_button.clicked, self.on_save_metadata_button_clicked) + connect(self.dialog_widget.edit_tags_input.enter_pressed, lambda: self.on_save_metadata_button_clicked(None)) + connect(self.dialog_widget.edit_tags_input.escape_pressed, self.close_dialog) + + self.dialog_widget.edit_tags_input.setFocus() + self.dialog_widget.error_text_label.hide() + self.dialog_widget.suggestions_container.hide() + + connect(self.dialog_widget.edit_metadata_table.doubleClicked, self.on_edit_metadata_table_item_clicked) + + # Load the languages + with open(get_ui_file_path("languages.json"), "r") as languages_file: + self.languages = json.loads(languages_file.read()) + + # Fill in the metadata table and make the items in the 2nd column editable + for ind in range(self.dialog_widget.edit_metadata_table.topLevelItemCount()): + item = self.dialog_widget.edit_metadata_table.topLevelItem(ind) + objects = get_objects_with_predicate(self.data_item, METADATA_TABLE_PREDICATES[ind]) + if METADATA_TABLE_PREDICATES[ind] == ResourceType.LANGUAGE: + # We use a drop-down menu to select the language of a torrent + combobox = QComboBox(self) + combobox.addItems(self.languages.values()) + self.dialog_widget.edit_metadata_table.setItemWidget(item, 1, combobox) + if objects and objects[0] in self.languages.keys(): + combobox.setCurrentIndex(list(self.languages.keys()).index(objects[0])) + else: + # Otherwise, we show an editing field + if objects: + item.setText(1, objects[0]) + item.setFlags(item.flags() | Qt.ItemIsEditable) + + if get_objects_with_predicate(self.data_item, ResourceType.TAG): + self.dialog_widget.edit_tags_input.set_tags(get_objects_with_predicate(self.data_item, ResourceType.TAG)) + self.dialog_widget.content_name_label.setText(self.data_item["name"]) + + # Fetch suggestions + TriblerNetworkRequest(f"knowledge/{self.infohash}/tag_suggestions", self.on_received_tag_suggestions) + + self.update_window() + + def on_edit_metadata_table_item_clicked(self, index): + if index.column() == 1: + item = self.dialog_widget.edit_metadata_table.topLevelItem(index.row()) + self.dialog_widget.edit_metadata_table.editItem(item, index.column()) + + def show_error_text(self, text: str) -> None: + self.dialog_widget.error_text_label.setText(tr(text)) + self.dialog_widget.error_text_label.setHidden(False) + + def on_save_metadata_button_clicked(self, _) -> None: + statements: List[Dict] = [] + + # Sanity check the entered tags + entered_tags = self.dialog_widget.edit_tags_input.get_entered_tags() + for tag in entered_tags: + if len(tag) < MIN_RESOURCE_LENGTH or len(tag) > MAX_RESOURCE_LENGTH: + error_text = f"Each tag should be at least {MIN_RESOURCE_LENGTH} characters and can be at most " \ + f"{MAX_RESOURCE_LENGTH} characters." + self.show_error_text(error_text) + return + + statements.append({ + "predicate": ResourceType.TAG, + "object": tag, + }) + + # Sanity check the entries in the metadata table and convert them to statements + for ind in range(self.dialog_widget.edit_metadata_table.topLevelItemCount()): + item = self.dialog_widget.edit_metadata_table.topLevelItem(ind) + entered_text: str = item.text(1) + + if METADATA_TABLE_PREDICATES[ind] == ResourceType.LANGUAGE: + combobox = self.dialog_widget.edit_metadata_table.itemWidget(item, 1) + if combobox.currentIndex() != 0: # Ignore the 'unknown' option in the dropdown menu at index zero + statements.append({ + "predicate": METADATA_TABLE_PREDICATES[ind], + "object": list(self.languages.keys())[combobox.currentIndex()], + }) + continue + + if entered_text and (len(entered_text) < MIN_RESOURCE_LENGTH or len(entered_text) > MAX_RESOURCE_LENGTH): + error_text = f"Each metadata item should be at least {MIN_RESOURCE_LENGTH} characters and can be at " \ + f"most {MAX_RESOURCE_LENGTH} characters." + self.show_error_text(error_text) + return + + # Check if the 'year' field is a number + if METADATA_TABLE_PREDICATES[ind] == ResourceType.DATE and entered_text and not entered_text.isdigit(): + error_text = "The year field should contain a valid year." + self.show_error_text(error_text) + return + + if entered_text: + statements.append({ + "predicate": METADATA_TABLE_PREDICATES[ind], + "object": entered_text, + }) + + self.save_button_clicked.emit(self.index, statements) + + def on_received_tag_suggestions(self, data: Dict) -> None: + self.suggestions_loaded.emit() + if data["suggestions"]: + self.dialog_widget.suggestions_container.show() + + cur_x = 0 + + for suggestion in data["suggestions"]: + tag_button = TagButton(self.dialog_widget.suggestions, suggestion) + connect(tag_button.clicked, lambda _, btn=tag_button: self.clicked_suggestion(btn)) + tag_button.move(QPoint(cur_x, tag_button.y())) + cur_x += tag_button.width() + TAG_HORIZONTAL_MARGIN + tag_button.show() + + self.update_window() + + def clicked_suggestion(self, tag_button: TagButton) -> None: + self.dialog_widget.edit_tags_input.add_tag(tag_button.text()) + tag_button.setParent(None) + + def update_window(self) -> None: + self.dialog_widget.adjustSize() + self.on_main_window_resize() diff --git a/src/tribler/gui/qt_resources/add_tags_dialog.ui b/src/tribler/gui/qt_resources/edit_metadata_dialog.ui similarity index 78% rename from src/tribler/gui/qt_resources/add_tags_dialog.ui rename to src/tribler/gui/qt_resources/edit_metadata_dialog.ui index b253f2a8fcf..ab593852982 100644 --- a/src/tribler/gui/qt_resources/add_tags_dialog.ui +++ b/src/tribler/gui/qt_resources/edit_metadata_dialog.ui @@ -6,8 +6,8 @@ 0 0 - 538 - 360 + 552 + 570 @@ -59,6 +59,24 @@ border: none; color: #C0C0C0; padding: 4px; margin-top: 4px; +} + +QTreeWidget::item { +color: white; +height: 24px; +} + +QTreeWidget { +background-color: #444; +} + +QTreeWidget QHeaderView::section { +background-color: #666; +font-size: 12px; +border: 1px solid #333; +padding: 5px; +padding-left: 10px; +margin: 0px; } @@ -134,7 +152,7 @@ margin-top: 4px; font-size: 16px; font-weight: bold; - Suggest Tags + Edit metadata Qt::AlignCenter @@ -157,6 +175,32 @@ margin-top: 4px; + + + + + 0 + 0 + + + + + 0 + 30 + + + + + 50 + true + false + + + + Content name + + + @@ -168,20 +212,20 @@ margin-top: 4px; 0 - 60 + 40 16777215 - 60 + 40 color: white; - <html><head/><body><p align="center">To organize the content in Tribler, this item would benefit from tags.<br/>You can help by suggesting several tags below that accurately describe this item (e.g., <span style=" font-style:italic;">video</span>).</p></body></html> + <html><head/><body><p align="center">To help organizing the content in Tribler, you can edit this items' data.</p></body></html> true @@ -189,9 +233,9 @@ margin-top: 4px; - + - + 0 0 @@ -199,19 +243,57 @@ margin-top: 4px; 0 - 30 + 135 - - - 50 - true - false - + + + 16777215 + 135 + - - Content name + + + + QAbstractItemView::NoEditTriggers + + + 5 + + + 200 + + + + Property + + + + + Value + + + + + Title + + + + + Description + + + + + Year + + + + + Language + + @@ -230,6 +312,50 @@ margin-top: 4px; + + + + + 0 + 0 + + + + + 0 + 50 + + + + + 16777215 + 50 + + + + <html><head/><body><p align="center">You can also help by suggesting several tags below that accurately describe this item (e.g., <span style=" font-style:italic;">video</span>).</p></body></html> + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Maximum + + + + 20 + 10 + + + + diff --git a/src/tribler/gui/qt_resources/languages.json b/src/tribler/gui/qt_resources/languages.json new file mode 100644 index 00000000000..d44c0417807 --- /dev/null +++ b/src/tribler/gui/qt_resources/languages.json @@ -0,0 +1,220 @@ +{ + "unknown": "Unknown", + "aa": "Afar", + "ab": "Abkhazian", + "af": "Afrikaans", + "am": "Amharic", + "ar": "Arabic", + "ar-ae": "Arabic (U.A.E.)", + "ar-bh": "Arabic (Bahrain)", + "ar-dz": "Arabic (Algeria)", + "ar-eg": "Arabic (Egypt)", + "ar-iq": "Arabic (Iraq)", + "ar-jo": "Arabic (Jordan)", + "ar-kw": "Arabic (Kuwait)", + "ar-lb": "Arabic (Lebanon)", + "ar-ly": "Arabic (libya)", + "ar-ma": "Arabic (Morocco)", + "ar-om": "Arabic (Oman)", + "ar-qa": "Arabic (Qatar)", + "ar-sa": "Arabic (Saudi Arabia)", + "ar-sy": "Arabic (Syria)", + "ar-tn": "Arabic (Tunisia)", + "ar-ye": "Arabic (Yemen)", + "as": "Assamese", + "ay": "Aymara", + "az": "Azeri", + "ba": "Bashkir", + "be": "Belarusian", + "bg": "Bulgarian", + "bh": "Bihari", + "bi": "Bislama", + "bn": "Bengali", + "bo": "Tibetan", + "br": "Breton", + "ca": "Catalan", + "co": "Corsican", + "cs": "Czech", + "cy": "Welsh", + "da": "Danish", + "de": "German", + "de-at": "German (Austria)", + "de-ch": "German (Switzerland)", + "de-li": "German (Liechtenstein)", + "de-lu": "German (Luxembourg)", + "div": "Divehi", + "dz": "Bhutani", + "el": "Greek", + "en": "English", + "en-au": "English (Australia)", + "en-bz": "English (Belize)", + "en-ca": "English (Canada)", + "en-gb": "English (United Kingdom)", + "en-ie": "English (Ireland)", + "en-jm": "English (Jamaica)", + "en-nz": "English (New Zealand)", + "en-ph": "English (Philippines)", + "en-tt": "English (Trinidad)", + "en-us": "English (United States)", + "en-za": "English (South Africa)", + "en-zw": "English (Zimbabwe)", + "eo": "Esperanto", + "es": "Spanish", + "es-ar": "Spanish (Argentina)", + "es-bo": "Spanish (Bolivia)", + "es-cl": "Spanish (Chile)", + "es-co": "Spanish (Colombia)", + "es-cr": "Spanish (Costa Rica)", + "es-do": "Spanish (Dominican Republic)", + "es-ec": "Spanish (Ecuador)", + "es-es": "Spanish (EspaƱa)", + "es-gt": "Spanish (Guatemala)", + "es-hn": "Spanish (Honduras)", + "es-mx": "Spanish (Mexico)", + "es-ni": "Spanish (Nicaragua)", + "es-pa": "Spanish (Panama)", + "es-pe": "Spanish (Peru)", + "es-pr": "Spanish (Puerto Rico)", + "es-py": "Spanish (Paraguay)", + "es-sv": "Spanish (El Salvador)", + "es-us": "Spanish (United States)", + "es-uy": "Spanish (Uruguay)", + "es-ve": "Spanish (Venezuela)", + "et": "Estonian", + "eu": "Basque", + "fa": "Farsi", + "fi": "Finnish", + "fj": "Fiji", + "fo": "Faeroese", + "fr": "French", + "fr-be": "French (Belgium)", + "fr-ca": "French (Canada)", + "fr-ch": "French (Switzerland)", + "fr-lu": "French (Luxembourg)", + "fr-mc": "French (Monaco)", + "fy": "Frisian", + "ga": "Irish", + "gd": "Gaelic", + "gl": "Galician", + "gn": "Guarani", + "gu": "Gujarati", + "ha": "Hausa", + "he": "Hebrew", + "hi": "Hindi", + "hr": "Croatian", + "hu": "Hungarian", + "hy": "Armenian", + "ia": "Interlingua", + "id": "Indonesian", + "ie": "Interlingue", + "ik": "Inupiak", + "in": "Indonesian", + "is": "Icelandic", + "it": "Italian", + "it-ch": "Italian (Switzerland)", + "iw": "Hebrew", + "ja": "Japanese", + "ji": "Yiddish", + "jw": "Javanese", + "ka": "Georgian", + "kk": "Kazakh", + "kl": "Greenlandic", + "km": "Cambodian", + "kn": "Kannada", + "ko": "Korean", + "kok": "Konkani", + "ks": "Kashmiri", + "ku": "Kurdish", + "ky": "Kirghiz", + "kz": "Kyrgyz", + "la": "Latin", + "ln": "Lingala", + "lo": "Laothian", + "ls": "Slovenian", + "lt": "Lithuanian", + "lv": "Latvian", + "mg": "Malagasy", + "mi": "Maori", + "mk": "FYRO Macedonian", + "ml": "Malayalam", + "mn": "Mongolian", + "mo": "Moldavian", + "mr": "Marathi", + "ms": "Malay", + "mt": "Maltese", + "my": "Burmese", + "na": "Nauru", + "nb-no": "Norwegian (Bokmal)", + "ne": "Nepali (India)", + "nl": "Dutch", + "nl-be": "Dutch (Belgium)", + "nn-no": "Norwegian", + "no": "Norwegian (Bokmal)", + "oc": "Occitan", + "om": "(Afan)/Oromoor/Oriya", + "or": "Oriya", + "pa": "Punjabi", + "pl": "Polish", + "ps": "Pashto/Pushto", + "pt": "Portuguese", + "pt-br": "Portuguese (Brazil)", + "qu": "Quechua", + "rm": "Rhaeto-Romanic", + "rn": "Kirundi", + "ro": "Romanian", + "ro-md": "Romanian (Moldova)", + "ru": "Russian", + "ru-md": "Russian (Moldova)", + "rw": "Kinyarwanda", + "sa": "Sanskrit", + "sb": "Sorbian", + "sd": "Sindhi", + "sg": "Sangro", + "sh": "Serbo-Croatian", + "si": "Singhalese", + "sk": "Slovak", + "sl": "Slovenian", + "sm": "Samoan", + "sn": "Shona", + "so": "Somali", + "sq": "Albanian", + "sr": "Serbian", + "ss": "Siswati", + "st": "Sesotho", + "su": "Sundanese", + "sv": "Swedish", + "sv-fi": "Swedish (Finland)", + "sw": "Swahili", + "sx": "Sutu", + "syr": "Syriac", + "ta": "Tamil", + "te": "Telugu", + "tg": "Tajik", + "th": "Thai", + "ti": "Tigrinya", + "tk": "Turkmen", + "tl": "Tagalog", + "tn": "Tswana", + "to": "Tonga", + "tr": "Turkish", + "ts": "Tsonga", + "tt": "Tatar", + "tw": "Twi", + "uk": "Ukrainian", + "ur": "Urdu", + "us": "English", + "uz": "Uzbek", + "vi": "Vietnamese", + "vo": "Volapuk", + "wo": "Wolof", + "xh": "Xhosa", + "yi": "Yiddish", + "yo": "Yoruba", + "zh": "Chinese", + "zh-cn": "Chinese (China)", + "zh-hk": "Chinese (Hong Kong SAR)", + "zh-mo": "Chinese (Macau SAR)", + "zh-sg": "Chinese (Singapore)", + "zh-tw": "Chinese (Taiwan)", + "zu": "Zulu" +} diff --git a/src/tribler/gui/utilities.py b/src/tribler/gui/utilities.py index 2ba24fbcce0..0cb5f34fed8 100644 --- a/src/tribler/gui/utilities.py +++ b/src/tribler/gui/utilities.py @@ -9,7 +9,7 @@ import types from datetime import datetime, timedelta from pathlib import Path -from typing import Callable, Dict +from typing import Callable, Dict, List from urllib.parse import quote_plus from uuid import uuid4 @@ -26,6 +26,7 @@ from PyQt5.QtWidgets import QApplication, QMessageBox import tribler.gui +from tribler.core.components.knowledge.db.knowledge_db import ResourceType from tribler.gui.defs import HEALTH_DEAD, HEALTH_GOOD, HEALTH_MOOT, HEALTH_UNCHECKED # fmt: off @@ -517,3 +518,10 @@ def get_color(name): blue = int(md5_str_hash[20:30], 16) % 128 + 100 return f'#{red:02x}{green:02x}{blue:02x}' + + +def get_objects_with_predicate(data_item: Dict, predicate: ResourceType) -> List[str]: + """ + Extract the objects that have a particular predicate from a particular data item. + """ + return [stmt["object"] for stmt in data_item.get("statements", ()) if stmt["predicate"] == predicate] diff --git a/src/tribler/gui/widgets/lazytableview.py b/src/tribler/gui/widgets/lazytableview.py index 7616b43a256..ec5419456f5 100644 --- a/src/tribler/gui/widgets/lazytableview.py +++ b/src/tribler/gui/widgets/lazytableview.py @@ -5,13 +5,12 @@ from PyQt5.QtGui import QGuiApplication, QMouseEvent, QMovie from PyQt5.QtWidgets import QAbstractItemView, QApplication, QHeaderView, QLabel, QTableView -from tribler.core.components.knowledge.db.knowledge_db import ResourceType from tribler.core.components.metadata_store.db.orm_bindings.channel_node import LEGACY_ENTRY from tribler.core.components.metadata_store.db.serialization import CHANNEL_TORRENT, COLLECTION_NODE, REGULAR_TORRENT, \ SNIPPET from tribler.gui.defs import COMMIT_STATUS_COMMITTED -from tribler.gui.dialogs.addtagsdialog import AddTagsDialog +from tribler.gui.dialogs.editmetadatadialog import EditMetadataDialog from tribler.gui.tribler_request_manager import TriblerNetworkRequest from tribler.gui.utilities import connect, data_item2uri, get_image_path, index2uri from tribler.gui.widgets.tablecontentdelegate import TriblerContentDelegate @@ -196,12 +195,7 @@ def on_subscribe_control_clicked(self, index): self.window().on_channel_subscribe(item) def on_edit_tags_clicked(self, index: QModelIndex) -> None: - data_item = index.model().data_items[index.row()] - self.add_tags_dialog = AddTagsDialog(self.window(), data_item["infohash"]) - self.add_tags_dialog.index = index - if data_item.get("tags", ()): - self.add_tags_dialog.dialog_widget.edit_tags_input.set_tags(data_item.get("tags", ())) - self.add_tags_dialog.dialog_widget.content_name_label.setText(data_item["name"]) + self.add_tags_dialog = EditMetadataDialog(self.window(), index) self.add_tags_dialog.show() connect(self.add_tags_dialog.save_button_clicked, self.save_edited_metadata) @@ -277,7 +271,7 @@ def on_metadata_edited(self, index, statements: List[Dict]): self.add_tags_dialog = None data_item = self.model().data_items[index.row()] - data_item["tags"] = [stmt["object"] for stmt in statements if stmt["predicate"] == ResourceType.TAG] + data_item["statements"] = statements self.redraw(index, True) self.edited_metadata.emit(data_item) diff --git a/src/tribler/gui/widgets/tablecontentdelegate.py b/src/tribler/gui/widgets/tablecontentdelegate.py index cc88a5c45b1..c9fd42ce1e7 100644 --- a/src/tribler/gui/widgets/tablecontentdelegate.py +++ b/src/tribler/gui/widgets/tablecontentdelegate.py @@ -6,6 +6,7 @@ from PyQt5.QtWidgets import QApplication, QComboBox, QStyle, QStyleOptionViewItem, QStyledItemDelegate, QToolTip from psutil import LINUX +from tribler.core.components.knowledge.db.knowledge_db import ResourceType from tribler.core.components.metadata_store.db.orm_bindings.channel_node import LEGACY_ENTRY from tribler.core.components.metadata_store.db.serialization import CHANNEL_TORRENT, COLLECTION_NODE, REGULAR_TORRENT, \ SNIPPET @@ -32,7 +33,8 @@ TAG_TOP_MARGIN, WINDOWS, ) -from tribler.gui.utilities import format_votes, get_color, get_gui_setting, get_health, get_image_path, tr +from tribler.gui.utilities import format_votes, get_color, get_gui_setting, get_health, get_image_path, tr, \ + get_objects_with_predicate from tribler.gui.widgets.tablecontentmodel import Column from tribler.gui.widgets.tableiconbuttons import DownloadIconButton @@ -185,7 +187,7 @@ def sizeHint(self, _, index: QModelIndex) -> QSize: cur_tag_x = 6 cur_tag_y = TAG_TOP_MARGIN - for tag_text in data_item.get("tags", ())[:MAX_TAGS_TO_SHOW]: + for tag_text in get_objects_with_predicate(data_item, ResourceType.TAG)[:MAX_TAGS_TO_SHOW]: text_width = self.font_metrics.horizontalAdvance(tag_text) tag_box_width = text_width + 2 * TAG_TEXT_HORIZONTAL_PADDING @@ -458,7 +460,7 @@ def draw_title_and_tags( edit_tags_button_hovered = self.hovering_over_tag_edit_button and self.hover_index == index # If there are no tags (yet), ask the user to add some tags - if len(data_item.get("tags", ())) == 0: + if len(get_objects_with_predicate(data_item, ResourceType.TAG)) == 0: no_tags_text = tr("Be the first to suggest tags!") painter.setPen(QColor(TRIBLER_ORANGE) if edit_tags_button_hovered else QColor("#aaa")) text_width = painter.fontMetrics().horizontalAdvance(no_tags_text) @@ -467,7 +469,7 @@ def draw_title_and_tags( painter.drawText(edit_tags_rect, no_tags_text) return - for tag_text in data_item.get("tags", ())[:MAX_TAGS_TO_SHOW]: + for tag_text in get_objects_with_predicate(data_item, ResourceType.TAG)[:MAX_TAGS_TO_SHOW]: text_width = painter.fontMetrics().horizontalAdvance(tag_text) tag_box_width = text_width + 2 * TAG_TEXT_HORIZONTAL_PADDING diff --git a/src/tribler/gui/widgets/tagslineedit.py b/src/tribler/gui/widgets/tagslineedit.py index e9a4e6bba06..41e7331a734 100644 --- a/src/tribler/gui/widgets/tagslineedit.py +++ b/src/tribler/gui/widgets/tagslineedit.py @@ -361,7 +361,7 @@ def paintEvent(self, _) -> None: # Draw the tags after the cursor. self.draw_tags(p, self.editing_index + 1, len(self.tags)) - else: + elif len(self.tags) > 1 or self.tags[0].text: self.draw_tags(p, 0, len(self.tags)) p.end()