Commit 5fb696ac authored by Abhishek Kumar Singh's avatar Abhishek Kumar Singh 👁 Committed by Alexandru Băluț
Browse files

Add tagging functionality for assets in the media library.

Fixes #537
parent 9901428e
Pipeline #262022 passed with stages
in 45 minutes and 18 seconds
<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>
......@@ -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>
......
......@@ -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)
......
......@@ -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)
......
......@@ -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())
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment