aboutsummaryrefslogtreecommitdiffstats
path: root/tagit/actions
diff options
context:
space:
mode:
authorMatthias Baumgartner <dev@igsor.net>2023-01-25 11:31:08 +0100
committerMatthias Baumgartner <dev@igsor.net>2023-01-25 11:31:08 +0100
commitf6de8a2f568419fd4ea818f3791242f177a87fba (patch)
tree1e105c50f8238accb747ea4050e574e50a30f108 /tagit/actions
parentb243b02de05fc247e554723137911f7d05b00b82 (diff)
downloadtagit-f6de8a2f568419fd4ea818f3791242f177a87fba.tar.gz
tagit-f6de8a2f568419fd4ea818f3791242f177a87fba.tar.bz2
tagit-f6de8a2f568419fd4ea818f3791242f177a87fba.zip
search actions early port
Diffstat (limited to 'tagit/actions')
-rw-r--r--tagit/actions/__init__.py8
-rw-r--r--tagit/actions/search.kv25
-rw-r--r--tagit/actions/search.py334
3 files changed, 363 insertions, 4 deletions
diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py
index c138655..bf99807 100644
--- a/tagit/actions/__init__.py
+++ b/tagit/actions/__init__.py
@@ -97,11 +97,11 @@ class ActionBuilder(BuilderBase):
'ShowBrowsing': planes.ShowBrowsing,
'ShowCodash': planes.ShowCodash,
## search
- #'Search': search.Search,
- #'ShowSelected': search.ShowSelected,
- #'RemoveSelected': search.RemoveSelected,
+ 'Search': search.Search,
+ 'ShowSelected': search.ShowSelected,
+ 'RemoveSelected': search.RemoveSelected,
#'SortKey': search.SortKey,
- #'SortOrder': search.SortOrder,
+ 'SortOrder': search.SortOrder,
## session
#'LoadSession': session.LoadSession,
#'CreateSession': session.CreateSession,
diff --git a/tagit/actions/search.kv b/tagit/actions/search.kv
new file mode 100644
index 0000000..00f6d6d
--- /dev/null
+++ b/tagit/actions/search.kv
@@ -0,0 +1,25 @@
+#:import resource_find kivy.resources.resource_find
+
+<Search>:
+ source: resource_find('atlas://search/search')
+ tooltip: 'Apply the current search filter'
+
+<ShowSelected>:
+ source: resource_find('atlas://search/exclusive_filter')
+ tooltip: 'Show only selected items'
+
+<RemoveSelected>:
+ source: resource_find('atlas://search/exclude_filter')
+ tooltip: 'Exclude selected items'
+
+<SortKey>:
+ source: resource_find('atlas://search/sort_key')
+ tooltip: 'Specify the sort key'
+
+<SortOrder>:
+ source_up: resource_find('atlas://search/sort_order_up')
+ source_down: resource_find('atlas://search/sort_order_down')
+ source: self.source_up
+ tooltip: 'Sort order'
+
+## EOF ##
diff --git a/tagit/actions/search.py b/tagit/actions/search.py
new file mode 100644
index 0000000..323f53e
--- /dev/null
+++ b/tagit/actions/search.py
@@ -0,0 +1,334 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import os
+
+# kivy imports
+from kivy.cache import Cache
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.external.kivy_garden.contextmenu import ContextMenu
+from tagit.utils import clamp, errors
+from tagit.utils import ns
+from tagit.utils.bsfs import ast
+from tagit.widgets import Binding
+from tagit.widgets.filter import FilterAwareMixin
+from tagit.widgets.session import StorageAwareMixin, ConfigAwareMixin
+#from tagit.ai.features.content import ContentFeature, FeatureBuilder # FIXME: mb/port
+#from tagit.parsing.search import sortkeys # FIXME: mb/port
+
+
+# inner-module imports
+from .action import Action
+
+# exports
+__all__ = []
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'search.kv'))
+
+# classes
+class Search(Action, StorageAwareMixin, ConfigAwareMixin):
+ """Apply the current search filter and update the browser."""
+ text = kp.StringProperty('Search')
+
+ # internal category for the cache
+ _CACHE_CATEGORY = 'tagit.search'
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'search', 'search'))
+
+ def on_root(self, wx, root):
+ Action.on_root(self, wx, root)
+ ConfigAwareMixin.on_root(self, wx, root)
+ StorageAwareMixin.on_root(self, wx, root)
+
+ def on_config_changed(self, session, key, value):
+ """Update cache settings."""
+ if self._CACHE_CATEGORY not in Cache._categories:
+ pass
+ elif key == ('ui', 'standalone', 'search', 'cache_items'):
+ value = None if value <= 0 else value
+ Cache._categories[self._CACHE_CATEGORY]['limit'] = value
+ elif key == ('ui', 'standalone', 'search', 'cache_timeout'):
+ value = None if value <= 0 else value
+ Cache._categories[self._CACHE_CATEGORY]['timeout'] = value
+
+ def on_cfg(self, wx, cfg):
+ """Initialize the cache."""
+ if self._CACHE_CATEGORY not in Cache._categories:
+ n_items = self.cfg('ui', 'standalone', 'search', 'cache_items')
+ n_items = None if n_items <= 0 else n_items
+ timeout = self.cfg('ui', 'standalone', 'search', 'cache_timeout')
+ timeout = None if timeout <= 0 else timeout
+ Cache.register(self._CACHE_CATEGORY, n_items, timeout)
+
+ def on_storage_modified(self, sender):
+ # clear the whole cache
+ Cache.remove(self._CACHE_CATEGORY, None)
+
+ def on_predicate_modified(self, sender, predicate, objects, diff):
+ self.apply()
+ return # FIXME: mb/port
+ tbd = set()
+ # walk through cache
+ for ast, sort in Cache._objects[self._CACHE_CATEGORY]:
+ # check ast
+ if ast is not None:
+ for token in ast:
+ if token.predicate() == predicate:
+ if predicate in ('tag', 'group') and \
+ len(set(diff) & set(token.condition())) == 0:
+ # tag predicate but the tag in question was not changed; skip
+ continue
+ tbd.add((ast, sort))
+ break # no need to search further
+
+ # check sort
+ if sort is not None:
+ if sort.predicate() == predicate:
+ tbd.add((ast, sort))
+
+ for key in tbd:
+ Cache.remove(self._CACHE_CATEGORY, key)
+
+ # re-apply searches
+ self.apply()
+
+ def apply(self):
+ browser = self.root.browser
+ filter = self.root.filter
+ session = self.root.session
+
+ with browser:
+ # get query
+ query, sort = filter.get_query()
+ # log search
+ # FIXME: mb/port/log
+ #session.log.log_search(
+ # 'filter',
+ # session.storage.lib.meta,
+ # (filter.t_head, filter.t_tail),
+ # (filter.f_head + [browser.frame], filter.f_tail),
+ # )
+
+ # apply search or fetch it from the cache
+ items = Cache.get(self._CACHE_CATEGORY, (query, sort), None)
+ if items is None:
+ # FIXME: mb/port: consider sort
+ items = list(session.storage.get(ns.bsfs.File, query))
+ Cache.append(self._CACHE_CATEGORY, (query, sort), items)
+
+ # apply search order because it's cheaper to do it here rather
+ # than in the backend (also see uix.kivy.filter.get_query).
+ items = list(reversed(items[:])) if filter.sortdir else items[:]
+ # update browser
+ browser.set_items(items)
+
+
+class ShowSelected(Action):
+ """Show only selected items."""
+ text = kp.StringProperty('Selected only')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'search', 'exclusive'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if len(browser.selection) == 0:
+ # silently ignore if no images selected
+ pass
+ elif len(browser.selection) == 1 and list(browser.selection)[0] in browser.folds:
+ # selection is a group
+ self.root.trigger('OpenGroup', list(browser.selection)[0])
+ else:
+ token = ast.filter.IsIn(browser.unfold(browser.selection))
+ self.root.trigger('AddToken', token)
+
+
+class RemoveSelected(Action):
+ """Exclude selected items."""
+ text = kp.StringProperty('Exclude selection')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'search', 'remove'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if len(browser.selection) == 0:
+ # silently ignore if no images selected
+ pass
+ else:
+ new_cursor = browser.neighboring_unselected()
+ token = ast.filter.IsNotIn(browser.unfold(browser.selection))
+ self.root.trigger('AddToken', token)
+ # fix frame
+ browser.cursor = new_cursor
+ browser.selection = {browser.cursor} if browser.cursor is not None else set()
+ self.root.trigger('JumpToCursor')
+
+
+class SortKey(Action):
+ """Select by which property items are ordered."""
+ text = kp.StringProperty('Sort by')
+
+ def apply(self, predicate=None):
+ if predicate is None:
+ x = self.pos[0] + self.width
+ y = self.pos[1] + self.height
+ self.menu.show(x, y)
+ else:
+ self.set_sortkey(predicate)
+
+ def on_root(self, wx, root):
+ super(SortKey, self).on_root(wx, root)
+
+ # Order is essential here:
+ # 1. the menu has to be created
+ # 2. the menu has to be attached to a parent
+ # 3. the menu has to be populated
+ # The visibility has to be triggered after (2) or (3)
+ self.menu = ContextMenu(
+ bounding_box_widget = self.root,
+ cancel_handler_widget = self.root)
+ self.root.add_widget(self.menu)
+ self.menu._on_visible(False)
+
+ # TODO: The whole sortkeys setup is rather brittle
+ # e.g. what happens if new features become available at runtime?
+
+ # default sortkeys
+ return # FIXME: mb/port
+
+ options = sortkeys.scope.library | sortkeys.typedef.anchored # FIXME: mb/port
+ # apply whitelist and blacklist config
+ options -= set(self.cfg('ui', 'standalone', 'search', 'sort_blacklist'))
+ whitelist = set(self.cfg('ui', 'standalone', 'search', 'sort_whitelist'))
+ whitelist = whitelist if len(whitelist) else options
+ options &= whitelist
+ # TODO: If there are several versions of the same feature class, keep only the most frequent
+ # * get feature predicates and their feature class (i.e. name)
+ # * if needed, get their frequencies via Features.Entities(ctrl.stor.num, fid)
+ # For now, all known features are used.
+
+ # populate menu
+ for sortkey in sorted(options):
+ text = sortkey
+ if ContentFeature.is_feature_id(sortkey):
+ text = FeatureBuilder.class_from_guid(sortkey).friendly_guid(sortkey)
+
+ self.menu.add_text_item(
+ text=text,
+ on_release=partial(self.release_wrapper, sortkey)
+ )
+
+ def release_wrapper(self, sortkey, *args):
+ # hide
+ self.menu.hide()
+ # trigger event
+ self.set_sortkey(sortkey)
+
+ def set_sortkey(self, predicate):
+ return # FIXME: mb/port
+ with self.root.filter as filter:
+ try:
+ # TODO: What if a predicate accepts several types (e.g. num and anchored)
+ if predicate in sortkeys.typedef.anchored:
+ cursor = self.root.browser.cursor
+ if cursor is None:
+ raise errors.UserError('an image needs to be selected for similarity sort.')
+ # TODO: We normally want the anchored search to be sorted most similar
+ # to least similar (sortdir=False). We could adjust the sortdir automatically.
+ # Note that VFilterAction_SortOrder would *not* get notified automatically.
+ filter.sortkey = partial(ast.AnchoredSort, predicate, cursor.guid)
+ elif predicate in sortkeys.typedef.numerical:
+ filter.sortkey = partial(ast.NumericalSort, predicate)
+ elif predicate in sortkeys.typedef.alphabetical:
+ filter.sortkey = partial(ast.AlphabeticalSort, predicate)
+ else:
+ raise errors.UserError('invalid sort key selected')
+
+ except Exception as e:
+ dialogues.Error(text=str(e)).open()
+
+ # stick to cursor
+ self.root.trigger('JumpToCursor')
+
+
+class SortOrder(Action, FilterAwareMixin):
+ """Switch between ascending and descending order."""
+ text = kp.StringProperty('Toggle sort order')
+
+ def on_root(self, wx, root):
+ Action.on_root(self, wx, root)
+ FilterAwareMixin.on_root(self, wx, root)
+
+ def on_sortdir(self, wx, sortdir):
+ if self._image is not None:
+ self._image.source = self.source_down if sortdir else self.source_up
+
+ def on_filter(self, wx, filter):
+ # remove old binding
+ if self.filter is not None:
+ self.filter.unbind(sortdir=self.on_sortdir)
+ # add new binding
+ self.filter = filter
+ if self.filter is not None:
+ self.filter.bind(sortdir=self.on_sortdir)
+ self.on_sortdir(self.filter, self.filter.sortdir)
+
+ def __del__(self):
+ # remove old binding
+ if self.filter is not None:
+ self.filter.unbind(sortdir=self.on_sortdir)
+ self.filter = None
+
+ def apply(self):
+ with self.root.filter as filter, \
+ self.root.browser as browser:
+ filter.sortdir = not filter.sortdir
+ # keep the same field of view as before
+ browser.offset = clamp(browser.n_items - (browser.offset + browser.page_size),
+ browser.max_offset)
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'search', 'sort_blacklist'), config.List(config.String()), [],
+ __name__, 'Blacklisted sortkeys', 'Sort keys that will not be shown in the sort selection. This does not affect whitelisted keys.')
+
+config.declare(('ui', 'standalone', 'search', 'sort_whitelist'), config.List(config.String()), [],
+ __name__, 'Whitelisted sortkeys', 'Sort keys that will always be shown in the sort selection. Overrules blacklisted keys.')
+
+config.declare(('ui', 'standalone', 'search', 'cache_items'), config.Unsigned(), 0,
+ __name__, 'Search cache size', 'Number of searches that are held in cache. Zero means no limit.')
+
+config.declare(('ui', 'standalone', 'search', 'cache_timeout'), config.Unsigned(), 0,
+ __name__, 'Search cache timeout', 'Number of seconds until searches are discarded from the search cache. Zero means no limit.')
+
+# keybindings
+
+config.declare(('bindings', 'search', 'search'),
+ config.Keybind(), Binding.simple(Binding.F5),
+ __name__, Search.text.defaultvalue, Search.__doc__)
+
+config.declare(('bindings', 'search', 'exclusive'),
+ config.Keybind(), Binding.simple(Binding.ENTER, Binding.mCTRL, Binding.mREST),
+ __name__, ShowSelected.text.defaultvalue, ShowSelected.__doc__)
+
+config.declare(('bindings', 'search', 'remove'),
+ config.Keybind(), Binding.simple(Binding.DEL, None, Binding.mALL),
+ __name__, RemoveSelected.text.defaultvalue, RemoveSelected.__doc__)
+
+## EOF ##