[pitivi] Add tagging functionality for assets in the media library.



commit 5fb696ac801957fdd0ce2db8a46b0e98a89cf85f
Author: AsociTon <asociton outlook com>
Date:   Mon Aug 10 00:56:51 2020 +0530

    Add tagging functionality for assets in the media library.
    
    Fixes #537

 data/pixmaps/tag-symbolic.svg |   3 +
 data/ui/medialibrary.ui       |  19 ++++
 pitivi/medialibrary.py        | 201 ++++++++++++++++++++++++++++++++++++++++++
 pitivi/undo/project.py        |   4 +
 tests/test_medialibrary.py    | 153 ++++++++++++++++++++++++++++++++
 5 files changed, 380 insertions(+)
---
diff --git a/data/pixmaps/tag-symbolic.svg b/data/pixmaps/tag-symbolic.svg
new file mode 100755
index 000000000..4ee4fc09a
--- /dev/null
+++ b/data/pixmaps/tag-symbolic.svg
@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg";>
+<path d="M0 1V7L6.69141 13.6914C6.87947 13.8795 7.10448 13.8955 7.28906 13.7109L13.6717 7.32831C13.8934 
7.10653 13.8787 6.87864 13.6918 6.69177L7 0H1.00015C0.535593 0 0 0.49129 0 1ZM3 1.96213C3.59045 1.96213 
4.06911 2.4408 4.06911 3.03125C4.06911 3.6217 3.59045 4.10037 3 4.10037C2.40955 4.10037 1.93089 3.6217 
1.93089 3.03125C1.93089 2.4408 2.40955 1.96213 3 1.96213Z" fill="#2E3436"/>
+</svg>
diff --git a/data/ui/medialibrary.ui b/data/ui/medialibrary.ui
index a1ec30bb6..b4b901aec 100644
--- a/data/ui/medialibrary.ui
+++ b/data/ui/medialibrary.ui
@@ -52,6 +52,25 @@
             <property name="homogeneous">True</property>
           </packing>
         </child>
+        <child>
+          <object class="GtkToggleToolButton" id="tags_button">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="tooltip_markup" translatable="yes">Show tags associated with selected 
clips</property>
+            <property name="use_underline">True</property>
+            <property name="icon_name">tag-symbolic</property>
+            <signal name="toggled" handler="_tags_button_clicked_cb" swapped="no"/>
+            <child internal-child="accessible">
+              <object class="AtkObject" id="tags_button-atkobject">
+                <property name="AtkObject::accessible-name">tags_button</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="homogeneous">True</property>
+          </packing>
+        </child>
         <child>
           <object class="GtkToolButton" id="media_insert_button">
             <property name="visible">True</property>
diff --git a/pitivi/medialibrary.py b/pitivi/medialibrary.py
index 31989810e..12b50c7e2 100644
--- a/pitivi/medialibrary.py
+++ b/pitivi/medialibrary.py
@@ -103,6 +103,31 @@ class AssetStoreItem(GObject.GObject):
         self.search_text = info_name(asset)
         self.thumb_decorator = thumb_decorator
 
+        # Fetch or Register tags
+        tags = asset.get_meta("pitivi::tags")
+        if not tags:
+            self.tags = set()
+            self.asset.register_meta(GES.MetaFlag.READWRITE, "pitivi::tags", "")
+        else:
+            self.tags = set(tags.split(","))
+
+
+class TagState(IntEnum):
+    """How the tag is associated with assets under selection."""
+
+    PRESENT = 1
+    INCONSISTENT = 2
+    ABSENT = 3
+
+
+class TagStoreItem(GObject.GObject):
+    """Data for displaying a Tag in the list."""
+
+    def __init__(self, name: str, initial_state: TagState):
+        GObject.GObject.__init__(self)
+        self.name = name
+        self.initial_state = initial_state
+
 
 class OptimizeOption(IntEnum):
     UNSUPPORTED_ASSETS = 0
@@ -485,6 +510,10 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         self._dragged_paths = []
         self.dragged = False
         self.rubberbanding = False
+        self.witnessed_tags = set()
+        self.tags_popover = None
+        self.new_tag_entry = None
+        self.__adding_tag = False
         self.clip_view = ViewType.__members__.get(self.app.settings.last_clip_view, ViewType.ICON)
         self.import_start_time = time.time()
         self._last_imported_uris = set()
@@ -525,6 +554,7 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         bg_color = bottom_toolbar_container.get_style_context().get_background_color(Gtk.StateFlags.NORMAL)
         bottom_toolbar.override_background_color(Gtk.StateFlags.NORMAL, bg_color)
         self._clipprops_button = builder.get_object("media_props_button")
+        self.tags_button = builder.get_object("tags_button")
 
         self.scrollwin = Gtk.ScrolledWindow()
         self.scrollwin.set_policy(
@@ -583,6 +613,8 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
                                self.remove_assets_action,
                                _("Remove the selected assets"))
 
+        # self.update_assets_tag.
+
         self.insert_at_end_action = Gio.SimpleAction.new("insert-assets-at-end", None)
         self.insert_at_end_action.connect("activate", self._insert_end_cb)
         actions_group.add_action(self.insert_at_end_action)
@@ -811,12 +843,29 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         for asset in self._pending_assets:
             thumb_decorator = AssetThumbnail(asset, self.app.proxy_manager)
             item = AssetStoreItem(asset, thumb_decorator)
+
+            asset.connect("notify-meta", self.asset_meta_changed_cb)
+            self.witnessed_tags.update(item.tags)
             self.store.append(item)
 
             thumb_decorator.connect("thumb-updated", self.__thumb_updated_cb, asset)
 
         del self._pending_assets[:]
 
+    def asset_meta_changed_cb(self, asset, meta_key, meta_value):
+        if meta_key != "pitivi::tags":
+            return
+
+        tags = set(meta_value.split(",")) if meta_value != "" else set()
+        for item in self.store:
+            if item.asset == asset:
+                item.tags = tags
+                # Rebuild witnessed_tags
+                self.witnessed_tags = set()
+                for item_ in self.store:
+                    self.witnessed_tags.update(item_.tags)
+                break
+
     def __thumb_updated_cb(self, asset_thumbnail, asset):
         """Handles the thumb-updated signal of the AssetThumbnails in the model."""
         pos = -1
@@ -1113,6 +1162,157 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         # In case the toggling is done when the items are filtered.
         self.filter_store()
 
+    def _tags_button_clicked_cb(self, unused_widget):
+        self.tags_popover = Gtk.Popover()
+        popover_heading = Gtk.Label(label=_("Tag as:"))
+        popover_heading.set_halign(Gtk.Align.START)
+
+        paths = self.get_selected_paths()
+
+        # Find the common tags in the selected assets
+        common_tags = list(self.store[paths[0]].tags)
+        for path in paths:
+            for tag in common_tags:
+                if tag not in self.store[path].tags:
+                    common_tags.remove(tag)
+            if not common_tags:
+                break
+
+        # Union of tags associated with assets under the current selection.
+        all_tags = []
+        for path in paths:
+            all_tags = list(set().union(all_tags, self.store[path].tags))
+        inconsistent_tags = list(set(all_tags) - set(common_tags))
+
+        tags = list(self.witnessed_tags)
+        tags.sort()
+        tagstore = Gio.ListStore()
+        for tag in tags:
+            if tag in common_tags:
+                state = TagState.PRESENT
+            elif tag in inconsistent_tags:
+                state = TagState.INCONSISTENT
+            else:
+                state = TagState.ABSENT
+            tagstore.append(TagStoreItem(tag, state))
+
+        tags_list = Gtk.ListBox()
+        tags_list.bind_model(tagstore, self.create_tagslist_widget_func)
+        tags_list.set_can_focus(False)
+
+        new_entry_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+        self.new_tag_entry = Gtk.Entry()
+        self.new_tag_entry.props.placeholder_text = _("Enter tag")
+        self.new_tag_entry.set_width_chars(12)
+        self.new_tag_entry.connect("activate", self.new_tag_entry_activated_cb, tagstore)
+        add_tag_button = Gtk.Button.new_with_label(_("Add"))
+        add_tag_button.connect("clicked", self.add_tag_button_clicked_cb, tagstore)
+        new_entry_box.pack_start(self.new_tag_entry, False, False, 0)
+        new_entry_box.pack_start(add_tag_button, False, False, PADDING)
+
+        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+        box.props.margin = PADDING
+        box.pack_start(popover_heading, False, False, SPACING)
+        box.pack_start(tags_list, True, True, PADDING)
+        box.pack_start(new_entry_box, False, False, 0)
+
+        self.tags_popover.connect("closed", self._tags_popover_closed_cb, tags_list, tagstore)
+        self.tags_popover.add(box)
+        self.tags_popover.set_position(Gtk.PositionType.BOTTOM)
+        self.tags_popover.set_relative_to(self.tags_button)
+        self.tags_popover.show_all()
+        self.tags_popover.popup()
+        tags_list.unselect_all()
+
+    def create_tagslist_widget_func(self, tag_item: TagStoreItem):
+        """Converts a tag item to a widget."""
+        box = Gtk.ListBoxRow()
+        box.props.margin = PADDING
+
+        checkbutton = Gtk.CheckButton.new_with_label(tag_item.name)
+
+        if tag_item.initial_state == TagState.INCONSISTENT:
+            checkbutton.props.inconsistent = True
+        active = self.__adding_tag or tag_item.initial_state == TagState.PRESENT
+        checkbutton.set_active(active)
+
+        checkbutton.connect("toggled", self._tag_toggled_cb, tag_item)
+
+        box.add(checkbutton)
+        box.show_all()
+
+        return box
+
+    def _tags_popover_closed_cb(self, popover, tags_list, tagstore):
+        self.apply_changed_tags(tags_list, tagstore)
+        popover.hide()
+        self.tags_button.set_active(False)
+        self.tags_popover = None
+
+    def _tag_toggled_cb(self, toggle_button, tag_item):
+        """Handles the toggling of a tag in the list."""
+        if toggle_button.props.inconsistent:
+            toggle_button.props.active = True
+            toggle_button.props.inconsistent = False
+        else:
+            if toggle_button.props.active:
+                if tag_item.initial_state == TagState.INCONSISTENT:
+                    toggle_button.props.inconsistent = True
+
+    def new_tag_entry_activated_cb(self, unused_widget, tagstore):
+        self._add_new_tag(tagstore)
+
+    def add_tag_button_clicked_cb(self, unused_widget, tagstore):
+        self._add_new_tag(tagstore)
+
+    def _add_new_tag(self, tagstore):
+        if not self.new_tag_entry.get_text():
+            return
+
+        tag_name = self.new_tag_entry.get_text()
+        # Don't accept duplicate entry
+        for tag_item in tagstore:
+            if tag_item.name == tag_name:
+                return
+
+        self.__adding_tag = True
+        try:
+            tagstore.append(TagStoreItem(tag_name, TagState.ABSENT))
+        finally:
+            self.__adding_tag = False
+
+        self.new_tag_entry.set_text("")
+
+    def apply_changed_tags(self, tags_list, tagstore):
+        paths = self.get_selected_paths()
+        with self.app.action_log.started("Alter tags", toplevel=True):
+            for i, row_widget in enumerate(tags_list):
+                checkbox = row_widget.get_child()
+                initial_state = tagstore[i].initial_state
+                tag_name = tagstore[i].name
+                if checkbox.props.inconsistent:
+                    # Nothing shall be changed.
+                    continue
+
+                if checkbox.props.active:
+                    if initial_state != TagState.PRESENT:
+                        for path in paths:
+                            asset_store_item = self.store[path]
+                            if tag_name not in asset_store_item.tags:
+                                old_meta = asset_store_item.asset.get_meta("pitivi::tags")
+                                new_meta = old_meta + "," + tag_name if old_meta else tag_name
+                                asset_store_item.asset.set_meta("pitivi::tags", new_meta)
+                else:
+                    if initial_state != TagState.ABSENT:
+                        for path in paths:
+                            asset_store_item = self.store[path]
+                            if tag_name in asset_store_item.tags:
+                                old_meta = asset_store_item.asset.get_meta("pitivi::tags")
+                                new_meta = old_meta.split(",")
+                                new_meta.remove(tag_name)
+                                new_meta = ",".join(new_meta)
+                                asset_store_item.asset.set_meta("pitivi::tags", new_meta)
+
     def __stop_using_proxy_cb(self, unused_action, unused_parameter):
         prefer_original = self.app.settings.proxying_strategy == ProxyingStrategy.NOTHING
         self._project.disable_proxies_for_assets(self.get_selected_assets(),
@@ -1288,6 +1488,7 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         selected_count = len(self.flowbox.get_selected_children())
         self.remove_assets_action.set_enabled(selected_count)
         self.insert_at_end_action.set_enabled(selected_count)
+        self.tags_button.set_sensitive(selected_count)
         # Some actions can only be done on a single item at a time:
         self._clipprops_button.set_sensitive(selected_count == 1)
 
diff --git a/pitivi/undo/project.py b/pitivi/undo/project.py
index 296cd5d5e..beba2240c 100644
--- a/pitivi/undo/project.py
+++ b/pitivi/undo/project.py
@@ -133,12 +133,16 @@ class ProjectObserver(MetaContainerObserver):
         MetaContainerObserver.__init__(self, project, action_log)
         project.connect("asset-added", self._asset_added_cb)
         project.connect("asset-removed", self._asset_removed_cb)
+        assets = project.list_assets(GES.Extractable)
+        for asset in assets:
+            MetaContainerObserver.__init__(self, asset, action_log)
         self.timeline_observer = TimelineObserver(project.ges_timeline,
                                                   action_log)
 
     def _asset_added_cb(self, unused_project, asset):
         if not isinstance(asset, GES.UriClipAsset):
             return
+        MetaContainerObserver.__init__(self, asset, self.action_log)
         action = AssetAddedAction(asset)
         self.action_log.push(action)
 
diff --git a/tests/test_medialibrary.py b/tests/test_medialibrary.py
index f077126b3..8636f74fe 100644
--- a/tests/test_medialibrary.py
+++ b/tests/test_medialibrary.py
@@ -26,6 +26,7 @@ from gi.repository import Gst
 
 from pitivi.medialibrary import AssetThumbnail
 from pitivi.medialibrary import MediaLibraryWidget
+from pitivi.medialibrary import TagState
 from pitivi.medialibrary import ViewType
 from pitivi.project import ProjectManager
 from pitivi.utils.misc import ASSET_DURATION_META
@@ -514,3 +515,155 @@ class TestMediaLibrary(BaseTestMediaLibrary):
         # Release click
         event = create_event(Gdk.EventType.BUTTON_RELEASE, button=3)
         mlib._flowbox_button_release_event_cb(mlib.flowbox, event)
+
+
+class TestTaggingAssets(BaseTestMediaLibrary):
+
+    def import_assets_in_medialibrary(self):
+        samples = ["30fps_numeroted_frames_red.mkv",
+                   "30fps_numeroted_frames_blue.webm", "1sec_simpsons_trailer.mp4"]
+        with common.cloned_sample(*samples):
+            self.check_import(samples, proxying_strategy=ProxyingStrategy.NOTHING)
+
+        self.assertTrue(self.medialibrary.tags_button.is_sensitive())
+
+    def add_new_tag(self, tag_name):
+        # Open the popover
+        self.medialibrary.tags_button.props.active = True
+        self.medialibrary.new_tag_entry.set_text(tag_name)
+        self.medialibrary.new_tag_entry.emit("activate")
+        self.medialibrary.tags_popover.hide()
+
+    def get_tags_list(self):
+        box = self.medialibrary.tags_popover.get_child()
+        popover_widgets = box.get_children()
+        return popover_widgets[1]
+
+    def assert_tags_popover(self, tags_state):
+        box = self.medialibrary.tags_popover.get_child()
+        popover_widgets = box.get_children()
+        tags_list = popover_widgets[1]
+
+        for row_widget in tags_list:
+            checkbox = row_widget.get_child()
+            tag_name = checkbox.get_label()
+            if tags_state[tag_name] == TagState.PRESENT:
+                self.assertTrue(checkbox.props.active)
+            elif tags_state[tag_name] == TagState.INCONSISTENT:
+                self.assertTrue(checkbox.props.inconsistent)
+            elif tags_state[tag_name] == TagState.ABSENT:
+                self.assertFalse(checkbox.props.active)
+            else:
+                raise Exception(tag_name)
+
+    def test_adding_tags(self):
+        self.mainloop = common.create_main_loop()
+        self.import_assets_in_medialibrary()
+        self.medialibrary.flowbox.unselect_all()
+
+        # Add a new tag "TAG" to asset1 via new tag entry field
+        asset1 = self.medialibrary.flowbox.get_child_at_index(0)
+        self.medialibrary.flowbox.select_child(asset1)
+        tag = "TAG"
+        self.add_new_tag(tag)
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.PRESENT})
+        self.medialibrary.tags_popover.hide()
+        self.assertEqual(self.medialibrary.store[0].tags, {tag})
+        self.assertEqual(self.medialibrary.witnessed_tags, {tag})
+        self.medialibrary.flowbox.unselect_all()
+
+        # Add an existing tag "TAG" to asset2 via toggling the unchecked mark
+        asset2 = self.medialibrary.flowbox.get_child_at_index(1)
+        self.medialibrary.flowbox.select_child(asset2)
+        # Check if the tag is unchecked for now
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.ABSENT})
+        tags_list = self.get_tags_list()
+        row_widget = tags_list.get_row_at_index(0)
+        checkbox = row_widget.get_child()
+        self.assertFalse(checkbox.props.active)
+        checkbox.props.active = True
+        self.medialibrary.tags_popover.hide()
+
+        # Check if "TAG" is applied to asset2
+        self.assertEqual(self.medialibrary.store[1].tags, {tag})
+        # Reopen popover to check if "TAG" is present and correctly checked in asset2
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.PRESENT})
+        self.medialibrary.tags_popover.hide()
+
+        # We have "TAG" for both asset1 and asset2, but asset3 has no tags
+        # Add the existing inconsistent tag to every selected asset
+        self.medialibrary.flowbox.select_all()
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.INCONSISTENT})
+        tags_list = self.get_tags_list()
+        row_widget = tags_list.get_row_at_index(0)
+        checkbox = row_widget.get_child()
+        # Make the tag present in all the tags
+        checkbox.props.active = True
+
+        # Reopen the tags popover to check if "TAG" is present in all assets
+        self.medialibrary.tags_popover.hide()
+        # Check if "TAG" is present in all the 3 assets
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.PRESENT})
+        self.medialibrary.tags_popover.hide()
+
+    def test_removing_tags(self):
+        self.mainloop = common.create_main_loop()
+        self.import_assets_in_medialibrary()
+        tag = "TAG"
+        self.add_new_tag(tag)
+        self.assertEqual(self.medialibrary.witnessed_tags, {tag})
+
+        # Make sure "TAG" is present in all the assets
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.PRESENT})
+        self.medialibrary.tags_popover.hide()
+        self.medialibrary.flowbox.unselect_all()
+
+        # Remove "TAG" from a single asset
+        child = self.medialibrary.flowbox.get_child_at_index(0)
+        self.medialibrary.flowbox.select_child(child)
+        self.medialibrary.tags_button.props.active = True
+        self.assertEqual(self.medialibrary.store[0].tags, {tag})
+        tags_list = self.get_tags_list()
+        row_widget = tags_list.get_row_at_index(0)
+        checkbox = row_widget.get_child()
+        self.assertTrue(checkbox.props.active)
+        checkbox.props.active = False
+        self.medialibrary.tags_popover.hide()
+
+        # Confirm "TAG" is removed from asset1
+        self.assertEqual(self.medialibrary.store[0].tags, set())
+        # Reopen the tags popover to check if "TAG" is removed from asset1
+        self.medialibrary.tags_button.props.active = True
+        self.assert_tags_popover({tag: TagState.ABSENT})
+        self.medialibrary.tags_popover.hide()
+
+        # Remove inconsistent "TAG" from the selected assets
+        self.medialibrary.flowbox.select_all()
+        self.medialibrary.tags_button.props.active = True
+
+        tags_list = self.get_tags_list()
+        row_widget = tags_list.get_row_at_index(0)
+        checkbox = row_widget.get_child()
+        self.assertTrue(checkbox.props.inconsistent)
+        # Toggle it to checked
+        checkbox.props.active = True
+        # Toggle it to unchecked
+        checkbox.props.active = False
+        self.medialibrary.tags_popover.hide()
+
+        # Check if popover is empty
+        self.medialibrary.tags_button.props.active = True
+        tags_list = self.get_tags_list()
+        row_widget = tags_list.get_row_at_index(0)
+        self.assertIsNone(row_widget)
+        self.medialibrary.tags_popover.hide()
+
+        self.assertEqual(self.medialibrary.witnessed_tags, set())
+        for item in self.medialibrary.store:
+            self.assertEqual(item.tags, set())


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]