""" 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): Cache.remove(self._CACHE_CATEGORY, None) # clears the whole cache 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.sorted(ns.bsn.Entity, 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 ##