class InstantsearchMainWindowExtension(WindowExtension): uimanager_xml = ''' <ui> <menubar name='menubar'> <menu action='tools_menu'> <placeholder name='plugin_items'> <menuitem action='instantsearch'/> </placeholder> </menu> </menubar> </ui> ''' gui = ""; @action(_('_Instantsearch'), accelerator='<ctrl>e') # T: menu item def instantsearch(self): #init self.cached_titles = [] #self.menu = defaultdict(_MenuItem) self.lastQuery = "" # previous user input self.queryO = None self.caret = {'pos':0, 'altPos':0, 'text':""} # cursor position self.originalPage = self.window.ui.page.name # we return here after escape self.selection = None if not self.plugin.preferences['isCached']: # reset last search results State.reset() self.menuPage = None self.isClosed = False self.lastPage = None # preferences self.title_match_char = self.plugin.preferences['title_match_char'] self.start_search_length = self.plugin.preferences['start_search_length'] self.keystroke_delay = self.plugin.preferences['keystroke_delay'] self.open_when_unique = self.plugin.preferences['open_when_unique'] # building quick title cache def build(start = ""): if hasattr(self.window.ui.notebook, 'pages'): o = self.window.ui.notebook.pages else: # for Zim 0.66- o = self.window.ui.notebook.index for s in o.list_pages(Path(start or ":")): start2 = (start + ":" if start else "") + s.basename self.cached_titles.append((start2, start2.lower())) build(start2) build() # Gtk self.gui = Dialog(self.window.ui, _('Search'), buttons=None, defaultwindowsize=(300, -1)) self.gui.resize(300, 100) # reset size self.inputEntry = InputEntry() self.inputEntry.connect('key_press_event', self.move) self.inputEntry.connect('changed', self.change) # self.change is needed by GObject or something self.gui.vbox.pack_start(self.inputEntry, False) self.labelObject = gtk.Label(('')) self.labelObject.set_usize(300, -1) self.gui.vbox.pack_start(self.labelObject, False) #gui geometry px, py = self.window.get_position() pw, ph = self.window.get_size() x, y = self.gui.get_position() if self.plugin.preferences['position'] == InstantsearchPlugin.POSITION_RIGHT: self.gui.move((pw-300), 0) elif self.plugin.preferences['position'] == InstantsearchPlugin.POSITION_CENTER: self.gui.resize(300, 100) self.gui.move(px + (pw / 2) - 150, py + (ph / 2) - 250) else: raise AttributeError("Instant search: Wrong position preference.") self.gui.show_all() self.labelVar = "" self.timeout = "" self.timeoutOpenPage = None #lastPage = "" #pageTitleOnly = False menu = [] #queryTime = 0 def change(self, _): #widget, event,text if self.timeout: gobject.source_remove(self.timeout) q = self.inputEntry.get_text() #print("Change. {} {}".format(input, self.lastQuery)) if q == self.lastQuery: return if q == self.title_match_char: return if q and q[-1] == "∀": # easter egg: debug option for zim --standalone q = q[:-1] import ipdb; ipdb.set_trace() self.state = State.setCurrent(q) if not self.state.isFinished: self.isSubset = True if self.lastQuery and q.startswith(self.lastQuery) else False self.state.checkTitleSearch(self.title_match_char) self.startSearch() else: # search completed before #print("Search already cached.") self.startSearch() # update the results in a page has been modified meanwhile (not if something got deleted in the notebook #16 ) self.checkLast() self.soutMenu() self.lastQuery = q def startSearch(self): """ Search string has certainly changed. We search in indexed titles and/or we start zim search. Normally, zim gives 11 points bonus if the search-string appears in the titles. If we are ignoring subpages, the search "foo" will match only page "journal:foo", but not "journal:foo:subpage" (and score of the parent page will get slightly higher by 1.) However, if there are occurences of the string in the fulltext of the subpage, subpage remains in the result, but gets bonus only 2 points (not 11). """ query = self.state.query menu = self.state.menu isInQuery = re.compile(r"(^|:|\s|\()" + query).search # 'te' matches this page titles: 'test' or 'Journal:test' or 'foo test' or 'foo (test)' if self.isSubset and len(query) < self.start_search_length: # letter(s) was/were added and full search has not yet been activated for path in _MenuItem.titles: if path in self.state.menu and not isInQuery(path.lower()): # 'te' didnt match 'test' etc del menu[path] # we pop out the result else: menu[path].sure = True else: # perform new search in cached_titles _MenuItem.titles = set() found = 0 if self.state.firstSeen: for path, pathLow in self.cached_titles: # quick search in titles if isInQuery(pathLow): # 'te' matches 'test' or 'Journal:test' etc _MenuItem.titles.add(path) if query in path.lower() and query not in path.lower().split(":")[-1]: # "raz" in "raz:dva", but not in "dva" self.state.menu[":".join(path.split(":")[:-1])].bonus += 1 # 1 point for subpage menu[path].bonus = -11 menu[path].score += 10 # 10 points for title (zim default) (so that it gets displayed before search finishes) menu[path].intitle = True menu[path].path = path found += 1 if found >= 10: # we dont want more than 10 results; we would easily match all of the pages break else: menu[path].intitle = False self.processMenu() # show for now results of title search if len(query) >= self.start_search_length: self.timeout = gobject.timeout_add(self.keystroke_delay, self.startZimSearch) # ideal delay between keystrokes def startZimSearch(self): """ Starts search for the input. """ self.timeout = "" self.caret['altPos'] = 0 # possible position of caret - beginning s = '"*{}*"'.format(self.state.query) if self.plugin.preferences['isWildcarded'] else self.state.query self.queryO = Query(unicode(s)) # beware when searching for unicode character. Update the row when going to Python3. lastSel = self.selection if self.isSubset and self.state.previous.isFinished else None # it should be quicker to find the string, if we provide this subset from last time (in the case we just added a letter, so that the subset gets smaller) self.selection = SearchSelection(self.window.ui.notebook) state = self.state # this is thread, so that self.state would can before search finishes self.selection.search(self.queryO, selection=lastSel, callback=self._search_callback(self.state.rawQuery)) state.isFinished = True for item in list(state.menu): # remove all the items that we didnt encounter during the search if not state.menu[item].sure: del state.menu[item] if state == self.state: self.checkLast() self.processMenu(state=state) def checkLast(self): """ opens the page if there is only one option in the menu """ if self.open_when_unique and len(self.state.menu) == 1: self._open_page(Path(self.state.menu.keys()[0]), excludeFromHistory=False) self.close() def _search_callback(self, query): def _search_callback(results, path): if results is not None: self._update_results(results, State.get(query)) # we finish the search even if another search is running. If we returned False, the search would be cancelled- while gtk.events_pending(): gtk.main_iteration(block=False) return True return _search_callback def _update_results(self, results, state): """ This method may run many times, due to the _update_results, which are updated many times. I may set that _update_results would run only once, but this is nice - the results are appearing one by one. """ changed = False state.lastResults = results for option in results.scores: if state.pageTitleOnly and state.query not in option.name: # hledame jen v nazvu stranky continue if option.name not in state.menu: # new item found if state == self.state and option.name == self.caret['text']: # this is current search self.caret['altPos'] = len(state.menu)-1 #karet byl na tehle pozici, pokud se zuzil vyber, budeme vedet, kam karet opravne umistit if option.name not in state.menu or (state.menu[option.name].bonus < 0 and state.menu[option.name].score == 0): changed = True if not state.menu[option.name].sure: state.menu[option.name].sure = True changed = True state.menu[option.name].score = results.scores[option] #zaradit mezi moznosti if changed: # we added a page self.processMenu(state=state, sort=False) else: pass def processMenu(self, state=None, sort=True): """ Sort menu and generate items and sout menu. """ if state is None: state = self.state if sort: state.items = sorted(state.menu, reverse=True, key=lambda item: (state.menu[item].intitle, state.menu[item].score + state.menu[item].bonus, -item.count(":"), item)) else: # when search results are being updated, it's good when the order doesnt change all the time. So that the first result does not become for a while 10th and then become first back. state.items = sorted(state.menu, reverse=True, key=lambda item: (state.menu[item].intitle, -state.menu[item].lastOrder)) state.items = [item for item in state.items if (state.menu[item].score + state.menu[item].bonus) > 0] # i dont know why there are items with 0 score if state == self.state: self.soutMenu() def soutMenu(self, displayImmediately=False): """ Displays menu and handles caret position. """ if self.timeoutOpenPage: gobject.source_remove(self.timeoutOpenPage) self.gui.resize(300, 100) # reset size # treat possible caret deflection if self.caret['pos'] < 0 or self.caret['pos'] > len(self.state.items)-1: # place the caret to the beginning or the end of list self.caret['pos'] = self.caret['altPos'] text = "" i = 0 for item in self.state.items: score = self.state.menu[item].score + self.state.menu[item].bonus self.state.menu[item].lastOrder = i if i == self.caret['pos']: self.caret['text'] = item # caret is at this position text += '→ {} ({}) {}\n'.format(item, score, "" if self.state.menu[item].sure else "?") else: try: text += '{} ({}) {}\n'.format(item, score, "" if self.state.menu[item].sure else "?") except: text += "CHYBA\n" text += item[0:-1] + "\n" i += 1 self.labelObject.set_text(text) self.menuPage = Path(self.caret['text'] if len(self.state.items) else self.originalPage) if not displayImmediately: self.timeoutOpenPage = gobject.timeout_add(self.keystroke_delay, self._open_page, self.menuPage) # ideal delay between keystrokes else: self._open_page(self.menuPage) def move(self, widget, event): """ Move caret up and down. Enter to confirm, Esc closes search.""" keyname = gtk.gdk.keyval_name(event.keyval) if keyname == "Up" or keyname == "ISO_Left_Tab": self.caret['pos'] -= 1 self.soutMenu(displayImmediately=False) if keyname == "Down" or keyname == "Tab": self.caret['pos'] += 1 self.soutMenu(displayImmediately=False) if keyname == "KP_Enter" or keyname == "Return": self._open_page(self.menuPage, excludeFromHistory=False) self.close() if keyname == "Escape": self._open_original() # GTK closes the windows itself on Escape, no self.close() needed return ## Safely closes # Xwhen closing directly, Python gave allocation error def close(self): if not self.isClosed: self.isClosed = True self.gui.emit("close") def _open_original(self): self._open_page(Path(self.originalPage)) def _open_page(self, page, excludeFromHistory=True): """ Open page and highlight matches """ self.timeoutOpenPage = None # no delayed page will be open if self.isClosed == True: return if page and page.name and page.name != self.lastPage: self.lastPage = page.name #print("*** HISTORY BEF", self.window.ui.history._history[-3:]) self.window.ui.open_page(page) if excludeFromHistory: # there is no public API, so lets use protected _history instead self.window.ui.history._history.pop() self.window.ui.history._current = len(self.window.ui.history._history) - 1 if not excludeFromHistory and self.window.ui.history.get_current().name is not page.name: # we insert the page to the history because it was likely to be just visited and excluded self.window.ui.history.append(page) # Popup find dialog with same query if self.queryO:# and self.queryO.simple_match: string = self.state.query string = string.strip('*') # support partial matches if self.plugin.preferences['highlight_search']: self._get_mainwindow().pageview.show_find(string, highlight=True) def _get_mainwindow(self): # #18 try: return self.window.ui._mainwindow except AttributeError: return self.window.ui.mainwindow
class InstantSearchMainWindowExtension(MainWindowExtension): gui: "Dialog" state: "State" def __init__(self, plugin, window): super().__init__(plugin, window) self.timeout = None self.timeout_open_page = None # will open page after keystroke delay self.timeout_open_page_preview = None # will open page after keystroke delay self.cached_titles = None self.last_query = None self.query_o = None self.caret = None self.original_page = None self.original_history = None self.selection = None self.menu_page = None self.is_closed = None self.last_page = self.last_page_preview = None self.label_object = None self.input_entry = None self.label_preview = None self.preview_pane = None self._last_update = 0 # preferences State.title_match_char = self.plugin.preferences['title_match_char'] State.start_search_length = self.plugin.preferences[ 'start_search_length'] self.keystroke_delay_open = self.plugin.preferences[ 'keystroke_delay_open'] self.keystroke_delay = self.plugin.preferences['keystroke_delay'] # noinspection PyArgumentList,PyUnresolvedReferences @action(_('_Instant search'), accelerator='<ctrl>e') # T: menu item def instant_search(self): # init self.cached_titles = [] self.last_query = "" # previous user input self.query_o = None self.caret = SimpleNamespace(pos=0, text="", stick=False) # cursor position self.original_page = self.window.page.name # we return here after escape self.original_history = list(self.window.history.uistate["list"]) self.selection = None if not self.plugin.preferences['is_cached']: # reset last search results State.reset() self.menu_page = None self.is_closed = False self.last_page = None # building quick title cache def build(start=""): o = self.window.notebook.pages for s in o.list_pages(Path(start or ":")): start2 = (start + ":" if start else "") + s.basename self.cached_titles.append((start2, start2.lower())) build(start2) build() # Gtk self.gui = Dialog(self.window, _('Search'), buttons=None, defaultwindowsize=(300, -1)) self.gui.resize(300, 100) # reset size self.input_entry = InputEntry() self.input_entry.connect('key_press_event', self.move) self.input_entry.connect( 'changed', self.change) # self.change is needed by GObject or something self.gui.vbox.pack_start(self.input_entry, expand=False, fill=True, padding=0) # noinspection PyArgumentList self.label_object = Gtk.Label(label='') self.label_object.set_size_request(300, -1) self.gui.vbox.pack_start(self.label_object, expand=False, fill=True, padding=0) # preview pane self.label_preview = Gtk.Label(label='...loading...') # not sure if this has effect, longer lines without spaces still make window inflate self.label_preview.set_line_wrap(True) self.label_preview.set_xalign(0) # align to the left self.preview_pane = Gtk.VBox() self.preview_pane.pack_start(self.label_preview, False, False, 5) self.window.pageview.pack_start(self.preview_pane, False, False, 5) # gui geometry self.geometry(init=True) self.gui.show_all() def geometry(self, init=False, repeat=True, force=False): if repeat and not init: # I do not know how to catch callback when result list's width is final, so we align several times [ GObject.timeout_add( x, lambda: self.geometry(repeat=False, force=force)) for x in (30, 50, 70, 400) ] # it is not worthy we continue now because often the Gtk redraw is delayed which would mean # the Dialog dimensions change twice in a row return px, py = self.window.get_position() pw, ph = self.window.get_size() if init: x, y = None, None w, h = 300, 100 else: x, y = self.gui.get_position() w, h = self.gui.get_allocated_width( ), self.gui.get_allocated_height() if self.plugin.preferences[ 'position'] == InstantSearchPlugin.POSITION_RIGHT: x2, y2 = pw - w, 0 elif self.plugin.preferences[ 'position'] == InstantSearchPlugin.POSITION_CENTER: x2, y2 = px + (pw / 2) - w / 2, py + (ph / 2) - 250 else: raise AttributeError("Instant search: Wrong position preference.") if init or x != x2 or force: self.gui.resize(300, 100) self.gui.move(x2, y2) def title(self, title=""): self.gui.set_title("Search " + title) def change(self, _): # widget, event,text if self.timeout: GObject.source_remove(self.timeout) self.timeout = None q = self.input_entry.get_text() if q == self.last_query: return if q == State.title_match_char: return if q and q[-1] == "∀": # easter egg: debug option for zim --standalone q = q[:-1] import ipdb ipdb.set_trace() self.state = State.set_current(q) if not self.state.is_finished: if self.start_search(): self.process_menu() else: # search completed before # update the results if a page has been modified meanwhile # (not if something got deleted in the notebook #16 ) self.start_search() self.check_last() self.sout_menu() self.last_query = q def start_search(self): """ Search string has certainly changed. We search in indexed titles and/or we start fulltext search. :rtype: True if no other search is needed and we may output the menu immediately. """ query = self.state.query menu = self.state.menu if not query: return self.process_menu() # show for now results of title search # 'te' matches these page titles: 'test' or 'Journal:test' or 'foo test' or 'foo (test)' sub_queries = [ re.compile(r"(^|:|\s|\()?" + q, re.IGNORECASE) for q in query.split(" ") ] def in_query(txt): """ False if any part of the query does not match. If the query is longer >3 characters: * +10 for every query part that matches a title part beginning Ex: query 'te' -> +3 for these page titles: 'test' or 'Journal:test' or 'foo test' or 'foo (test)' * +1 for every query part Ex: query 'st' -> +1 for those page titles If the query is shorter <=3 characters: +10 for every query part that matches a title part beginning 'te' for 'test' False otherwise ('st' for 'test') so that you do not end up messed with page titles, after writing a single letter. """ arr = (q.search(txt) for q in sub_queries) try: if len(query) <= 3: # raises if subquery m does not match or is not at a page chunk beginning return sum(10 if m.group(1) is not None else None for m in arr) else: # raises if subquery m does not match return sum(10 if m.group(1) is not None else 1 for m in arr) except (AttributeError, TypeError ): # one of the sub_queries is not part of the page title return False # we loop either all cached page titles or menu that should be built from previous superset-query menu it = ((x, x.lower()) for x in list(menu)) if menu else self.cached_titles for path, path_low in it: # quick search in titles score = in_query(path_low) if score: # 'te' matches 'test' or 'Journal:test' etc # "foo" in "foo:bar", but not in "bar" # when looping "foo:bar", page "foo" receives +1 for having a subpage if all(q in path.lower() for q in query) \ and any(q not in path.lower().split(":")[-1] for q in query): menu[":".join(path.split( ":")[:-1])].bonus += 1 # 1 point for having a subpage # Normally, zim search gives 11 points bonus if the search-string appears in the titles. # If we are ignoring sub-pages, the search "foo" will match only page "journal:foo", # but not "journal:foo:subpage" # (and score of the parent page will get slightly higher by 1.) # However, if there are occurrences of the string in the fulltext of the subpage, # subpage remains in the result, but gets bonus only 2 points (not 11). # But internal zim search is now disabled. # menu[path].bonus = -11 else: # 10 points for title (zim default) (so that it gets displayed before search finishes) menu[ path].bonus += score # will be added to score (score will be reset) # if score > 9, it means this might be priority match, not fulltext header search # ex "te" for "test" is priority, whereas "st" is just fulltext menu[path].in_title = True if score > 9 else False menu[path].path = path # menu[path].sure = True if self.state.page_title_only: return True else: if not self.state.previous or len( query) == State.start_search_length: # quickly show page title search results before longer fulltext search is ready # Either there is no previous state – query might have been copied into input # or the query is finally long enough to start fulltext search. # It is handy to show out filtered page names before because # it is often use case to jump to queries matched in page names. self.process_menu(ignore_geometry=True) self.title("..") self.timeout = GObject.timeout_add( self.keystroke_delay, self.start_zim_search) # ideal delay between keystrokes def start_zim_search(self): """ Starts search for the input. """ self.title("...") if self.timeout: GObject.source_remove(self.timeout) self.timeout = None self.query_o = Query(self.state.query) # it should be quicker to find the string, if we provide this subset from last time # (in the case we just added a letter, so that the subset gets smaller) # last_sel = self.selection if self.is_subset and self.state.previous and self.state.previous.is_finished # else None selection = self.selection = SearchSelection(self.window.notebook) state = self.state # this is a thread, so that self.state might change before search finishes # internal search disabled - it was way too slower # selection.search(self.query_o, selection=last_sel, callback=self._search_callback(state)) # self._update_results(selection, state, force=True) # self.title("....") # fulltext external search # Loop either all .txt files in the notebook or narrow the search with a previous state if state.previous and state.previous.is_finished and state.previous.matching_files is not None: paths_set = state.previous.matching_files # see below paths_cached_set = (p for p in files_set if p in InstantSearchPlugin.file_cache) else: paths_set = (f for f in pathlib.Path( str(self.window.notebook.folder)).rglob("*.txt") if f.is_file()) # see below paths_cached_set = (p for p in InstantSearchPlugin.file_cache) state.matching_files = [] # This cached search takes about 60 ms, so I let it commented. # However on HDD disks this may boost performance. # We may do an option: "empty cache immediately after close (default)", # "search cache first and then do the fresh search (HDD)" # "use cache always (empties cache after Zim restart)" # "empty cache after 5 minutes" # and then prevent to clear the cache in .close(). # Or rather we may read file mtime and re-read if only it has been changed since last search. # if not InstantSearchPlugin.file_cache_fresh: # # Cache might not be fresh but since it is quick, perform quick non-fresh-cached search # # and then do a fresh search. If we are lucky enough, results will not change. # # using temporary selection so that files will not received double points for both cached and fresh loop # selection_temp = SearchSelection(self.window.notebook) # self.start_external_search(selection_temp, state, paths_cached_set) # InstantSearchPlugin.file_cache_fresh = True # InstantSearchPlugin.file_cache.clear() self.start_external_search(selection, state, paths_set) state.is_finished = True # for item in list(state.menu): # remove all the items that we didnt encounter during the search # if not state.menu[item].sure: # del state.menu[item] if state == self.state: self.check_last() self.process_menu(state=state) self.title() def start_external_search(self, selection, state: "State", paths): """ Zim internal search is not able to find out text with markup. Ex: 'economical' is not recognized as 'economi**cal**' (however highlighting works great), as 'economi[[inserted link]]cal' as 'any text with [[http://economical.example.com|link]]' This fulltext search loops all .txt files in the notebook directory and tries to recognize the patterns. """ # divide query to independent words "foo economical" -> "foo", "economical", page has to contain both # strip markup: **bold**, //italic//, __underline__, ''verbatim'', ~~strike through~~ # matches query "economi**cal**" def letter_split(q): """ Every letter is divided by a any-formatting-match-group and escaped. 'foo.' -> 'f[*/'_~]o[*/'_~]o[*/'_~]\\.' """ return r"[*/'_~]*".join((re.escape(c) for c in list(q))) sub_queries = state.query.split(" ") # regex to identify in all sub_queries present in the text queries = [ re.compile(letter_split(q), re.IGNORECASE) for q in sub_queries ] # regex to identify the very query is present exact_query = re.compile(letter_split( state.query), re.IGNORECASE) if len(sub_queries) > 1 else None # regex to count the number of the sub_queries present and to optionally add information about header used header_queries = [ re.compile("(\n=+ .*)?" + letter_split(q), re.IGNORECASE) for q in sub_queries ] # regex to identify inner link contents link = re.compile( r"\[\[(.*?)\]\]", re.IGNORECASE) # matches all links "economi[[inserted link]]cal" for p in paths: if p not in InstantSearchPlugin.file_cache: s = p.read_text() # strip header if s.startswith('Content-Type: text/x-zim-wiki'): # XX will that work on Win? # I should use more general separator IMHO in the whole file rather than '\n'. s = s[s.find("\n\n"):] InstantSearchPlugin.file_cache[p] = s else: s = InstantSearchPlugin.file_cache[p] matched_links = [] def matched_link(match): matched_links.append(match.group(1)) return "" # pull out links "economi[[inserted link]]cal" -> "economical" + "inserted link" txt_body = link.sub(matched_link, s) txt_links = "".join(matched_links) if all( query.search(txt_body) or query.search(txt_links) for query in queries): path = self.window.notebook.layout.map_file(File(str(p)))[0] # score = header order * 3 + body match count * 1 # if there are '=' equal chars before the query, it is header. The bigger number, the bigger header. # Header 5 corresponds to 3 points, Header 1 to 7 points. score = sum([ len(m.group(1)) * 3 if m.group(1) else 1 for q in header_queries for m in q.finditer(txt_body) ]) if exact_query: # there are sub-queries, we favourize full-match score += 50 * len(exact_query.findall(txt_body)) # noinspection PyProtectedMember # score might be zero because we are not re-checking against txt_links matches selection._count_score(path, score or 1) state.matching_files.append(p) self._update_results(selection, state, force=True) def check_last(self): """ opens the page if there is only one option in the menu """ if len(self.state.menu ) == 1 and self.plugin.preferences['open_when_unique']: self._open_page(Path(list(self.state.menu)[0]), exclude_from_history=False) self.close() elif not len(self.state.menu): self._open_original() def _search_callback(self, state): def _(results, _path): if results is not None: # we finish the search even if another search is running. # If returned False, the search would be cancelled self._update_results(results, state) while Gtk.events_pending(): Gtk.main_iteration() return True return _ def _update_results(self, results, state: "State", force=False): """ This method may run many times, due to the _update_results, which are updated many times, the results are appearing one by one. However, if called earlier than 0.2 s, ignored. Measures: If every callback would be counted, it takes 3500 ms to build a result set. If callbacks earlier than 0.6 s -> 2300 ms, 0.3 -> 2600 ms, 0.1 -> 2800 ms. """ if not force and time( ) < self._last_update + 0.2: # if update callback called earlier than 200 ms, ignore return self._last_update = time() changed = False for option in results.scores: if option.name not in state.menu or ( state.menu[option.name].bonus < 0 and state.menu[option.name].score == 0): changed = True o: _MenuItem = state.menu[option.name] # if not o.sure: # o.sure = True # changed = True o.score = results.scores[option] # includes into options if changed: # we added a page self.process_menu(state=state, sort=False) else: pass def process_menu(self, state=None, sort=True, ignore_geometry=False): """ Sort menu and generate items and sout menu. """ if state is None: state = self.state if sort: state.items = sorted( state.menu, reverse=True, key=lambda item: (state.menu[item].in_title, state.menu[item].score + state. menu[item].bonus, -item.count(":"), item)) else: # when search results are being updated, it's good when the order doesnt change all the time. # So that the first result does not become for a while 10th and then become first back. state.items = sorted( state.menu, reverse=True, key=lambda item: (state.menu[item].in_title, -state.menu[item].last_order)) # I do not know why there are items with score 0 if internal Zim search used state.items = [ item for item in state.items if (state.menu[item].score + state.menu[item].bonus) > 0 ] if state == self.state: self.sout_menu(ignore_geometry=ignore_geometry) def sout_menu(self, display_immediately=False, caret_move=None, ignore_geometry=False): """ Displays menu and handles caret position. """ if self.timeout_open_page: GObject.source_remove(self.timeout_open_page) self.timeout_open_page = None if self.timeout_open_page_preview: GObject.source_remove(self.timeout_open_page_preview) self.timeout_open_page_preview = None # caret: # by default stays at position 0 # If moved to a page, it keeps the page. # If moved back to position 0, stays there. if caret_move is not None: if caret_move == 0: self.caret.pos = 0 else: self.caret.pos += caret_move self.caret.stick = self.caret.pos != 0 elif self.state.items and self.caret.stick: # identify current caret position, depending on the text self.caret.pos = next((i for i, item in enumerate(self.state.items) if item == self.caret.text), 0) # treat possible caret deflection if self.caret.pos < 0: # place the caret to the beginning or the end of list self.caret.pos = 0 elif self.caret.pos >= len(self.state.items): self.caret.pos = 0 if caret_move == 1 else len( self.state.items) - 1 text = [] i = 0 for item in self.state.items: score = self.state.menu[item].score + self.state.menu[item].bonus self.state.menu[item].last_order = i pieces = item.split(":") pieces[-1] = f"<b>{pieces[-1]}</b>" s = ":".join(pieces) if i == self.caret.pos: self.caret.text = item # caret is at this position # text += f'→ {s} ({score}) {"" if self.state.menu[item].sure else "?"}\n' text.append(f'→ {s} ({score})') else: # text += f'{s} ({score}) {"" if self.state.menu[item].sure else "?"}\n' text.append(f'{s} ({score})') i += 1 text = "No result" if not text and self.state.is_finished else "\n".join( text) self.label_object.set_markup(text) self.menu_page = Path( self.caret.text if len(self.state.items) else self.original_page) if not display_immediately: if self.plugin.preferences[ 'preview_mode'] != InstantSearchPlugin.PREVIEW_ONLY: self.timeout_open_page = GObject.timeout_add( self.keystroke_delay_open, self._open_page, self.menu_page) # ideal delay between keystrokes if self.plugin.preferences[ 'preview_mode'] != InstantSearchPlugin.FULL_ONLY: self.timeout_open_page_preview = GObject.timeout_add( self.keystroke_delay, self._open_page_preview, self.menu_page) # ideal delay between keystrokes else: self._open_page(self.menu_page) # we force here geometry to redraw because often we end up with "No result" page that is very tall # because of a many records just hidden if not ignore_geometry: self.geometry(force=True) def move(self, widget, event): """ Move caret up and down. Enter to confirm, Esc closes search.""" key_name = Gdk.keyval_name(event.keyval) # handle basic caret movement moves = { "Up": -1, "ISO_Left_Tab": -1, "Down": 1, "Tab": 1, "Page_Up": -10, "Page_Down": 10 } if key_name in moves: self.sout_menu(display_immediately=False, caret_move=moves[key_name]) elif key_name in ("Home", "End"): if event.state & Gdk.ModifierType.CONTROL_MASK or event.state & Gdk.ModifierType.SHIFT_MASK: # Ctrl/Shift+Home jumps to the query input text start return if key_name == "Home": # Home jumps at the result list start self.sout_menu(display_immediately=False, caret_move=0) widget.emit_stop_by_name("key-press-event") else: self.sout_menu(display_immediately=False, caret_move=float("inf")) widget.emit_stop_by_name("key-press-event") # confirm or cancel elif key_name == "KP_Enter" or key_name == "Return": self._open_page(self.menu_page, exclude_from_history=False) self.close() elif key_name == "Escape": self._open_original() self.is_closed = True # few more timeouts are on the way probably self.close() return def close(self): """ Safely (closes gets called when hit Enter) """ if not self.is_closed: # if hit Esc, GTK has already emitted close itself self.is_closed = True self.gui.emit("close") # remove preview pane and show current text editor self._hide_preview() self.preview_pane.destroy() InstantSearchPlugin.file_cache.clear( ) # until next search, pages might change InstantSearchPlugin.file_cache_fresh = False def _open_original(self): self._open_page(Path(self.original_page)) # we already have HistoryPath objects in the self.original_history, we cannot add them in the constructor # XX I do not know what is that good for hl = HistoryList([]) hl.extend(self.original_history) self.window.history.uistate["list"] = hl # noinspection PyProtectedMember def _open_page(self, page, exclude_from_history=True): """ Open page and highlight matches """ self._hide_preview() if self.timeout_open_page: # no delayed page will be open GObject.source_remove(self.timeout_open_page) self.timeout_open_page = None if self.timeout_open_page_preview: # no delayed preview page will be open GObject.source_remove(self.timeout_open_page_preview) self.timeout_open_page_preview = None # open page if page and page.name and page.name != self.last_page: self.last_page = page.name self.window.navigation.open_page(page) if exclude_from_history and list( self.window.history._history )[-1:][0].name != self.original_page: # there is no public API, so lets use protected _history instead self.window.history._history.pop() self.window.history._current = len( self.window.history._history) - 1 if not exclude_from_history and self.window.history.get_current( ).name is not page.name: # we insert the page to the history because it was likely to be just visited and excluded self.window.history.append(page) # Popup find dialog with same query if self.query_o: # and self.query_o.simple_match: string = self.state.query string = string.strip('*') # support partial matches if self.plugin.preferences['highlight_search']: # unfortunately, we can highlight single word only self.window.pageview.show_find(string.split(" ")[0], highlight=True) def _hide_preview(self): self.preview_pane.hide() # noinspection PyProtectedMember self.window.pageview._hack_hbox.show() def _open_page_preview(self, page): """ Open preview which is far faster then loading and building big parse trees into text editor buffer when opening page. """ # note: if the dialog is already closed, we do not want a preview to open, but page still can be open # (ex: after hitting Enter the dialog can close before opening the page) if self.timeout_open_page_preview: # no delayed preview page will be open, however self.timeout_open_page might be still running GObject.source_remove(self.timeout_open_page_preview) self.timeout_open_page_preview = None # it does not pose a problem if we re-load preview on the same page; # the query text might got another letter to highlight if page and not self.is_closed: # show preview pane and hide current text editor self.last_page_preview = page.name local_file = self.window.notebook.layout.map_page(page)[0] path = pathlib.Path(str(local_file)) if path in InstantSearchPlugin.file_cache: s = InstantSearchPlugin.file_cache[path] else: try: s = InstantSearchPlugin.file_cache[path] = local_file.read( ) except newfs.base.FileNotFoundError: s = f"page {page} has no content" # page has not been created yet lines = s.splitlines() # the file length is very small, prefer to not use preview here if self.plugin.preferences[ 'preview_mode'] != InstantSearchPlugin.PREVIEW_ONLY and len( lines) < 50: return self._open_page(page, exclude_from_history=True) self.label_preview.set_markup( self._get_preview_text(lines, self.state.query)) # shows GUI (hidden in self._hide_preview() self.preview_pane.show_all() # noinspection PyProtectedMember self.window.pageview._hack_hbox.hide() def _get_preview_text(self, lines, query): max_lines = 200 # check if the file is a Zim markup file and if so, skip header if lines[0] == 'Content-Type: text/x-zim-wiki': for i, line in enumerate(lines): if line == "": lines = lines[i + 1:] break if query.strip() == "": return "\n".join(line for line in lines[:max_lines]) # searching for "a" cannot match "&a", since markup_escape_text("&") -> "'" # Ignoring q == "b", it would interfere with multiple queries: # Ex: query "f b", text "foo", matched with "f" -> "<b>f</b>oo", matched with "b" -> "<<b>b</b>>f</<b>b</b>>" query_match = (re.compile("(" + re.escape(q) + ")", re.IGNORECASE) for q in query.split(" ") if q != "b") # too long lines caused strange Gtk behaviour – monitor brightness set to maximum, without any logged warning # so that I decided to put just extract of such long lines in preview # This regex matches query chunk in the line, prepends characters before and after. # When there should be the same query chunk after the first, it stops. # Otherwise, the second chunk might be halved and thus not highlighted. # Ex: query "test", text: "lorem ipsum text dolor text text sit amet consectetur" -> # ["ipsum text dolor ", "text ", "text sit amet"] (words "lorem" and "consectetur" are strip) line_extract = [ re.compile( "(.{0,80}" + re.escape(q) + "(?:(?!" + re.escape(q) + ").){0,80})", re.IGNORECASE) for q in query.split(" ") if q != "b" ] # grep some lines keep_all = not self.plugin.preferences["preview_short"] and len( lines) < max_lines lines_iter = iter(lines) chosen = [ next(lines_iter) ] # always include header as the first line, even if it does not contain the query for line in lines_iter: if len( chosen ) > max_lines: # file is too long which would result the preview to not be smooth break elif keep_all or any(q in line.lower() for q in query.split(" ")): # keep this line since it contains a query chunk if len(line) > 100: # however, this line is too long to display, try to extract query and its neighbourhood s = "...".join("...".join(q.findall(line)) for q in line_extract).strip(".") if not s: # no query chunk was find on this line, the keep_all is True for sure chosen.append(line[:100] + "...") else: chosen.append("..." + s + "...") else: chosen.append(line) if not keep_all or len(chosen) > max_lines: # note that query might not been found, ex: query "foo" would not find line with a bold 'o': "f**o**o" chosen.append("...") txt = markup_escape_text("\n".join(line for line in chosen)) # bold query chunks in the text for q in query_match: txt = q.sub(r"<b>\g<1></b>", txt) # preserve markup_escape_text entities # correct ex: '&a<b>m</b>p;' -> '&' if searching for 'm' bold_tag = re.compile("</?b>") broken_entity = re.compile("&[a-z]*<b[^;]*;") txt = broken_entity.sub(lambda m: bold_tag.sub("", m.group(0)), txt) return txt
class InstantsearchMainWindowExtension(MainWindowExtension): uimanager_xml = ''' <ui> <menubar name='menubar'> <menu action='tools_menu'> <placeholder name='plugin_items'> <menuitem action='instantsearch'/> </placeholder> </menu> </menubar> </ui> ''' gui = "" @action(_('_Instantsearch'), accelerator='<ctrl>e') # T: menu item def instantsearch(self): # init self.cached_titles = [] # self.menu = defaultdict(_MenuItem) self.lastQuery = "" # previous user input self.queryO = None self.caret = {'pos': 0, 'altPos': 0, 'text': ""} # cursor position self.originalPage = self.window.page.name # we return here after escape self.selection = None if not self.plugin.preferences['isCached']: # reset last search results State.reset() self.menuPage = None self.isClosed = False self.lastPage = None # preferences self.title_match_char = self.plugin.preferences['title_match_char'] self.start_search_length = self.plugin.preferences[ 'start_search_length'] self.keystroke_delay = self.plugin.preferences['keystroke_delay'] self.open_when_unique = self.plugin.preferences['open_when_unique'] # building quick title cache def build(start=""): if hasattr(self.window.notebook, 'pages'): o = self.window.notebook.pages else: # for Zim 0.66- o = self.window.notebook.index for s in o.list_pages(Path(start or ":")): start2 = (start + ":" if start else "") + s.basename self.cached_titles.append((start2, start2.lower())) build(start2) build() # Gtk self.gui = Dialog(self.window, _('Search'), buttons=None, defaultwindowsize=(300, -1)) self.gui.resize(300, 100) # reset size self.inputEntry = InputEntry() self.inputEntry.connect('key_press_event', self.move) self.inputEntry.connect( 'changed', self.change) # self.change is needed by GObject or something self.gui.vbox.pack_start(self.inputEntry, expand=False, fill=True, padding=0) self.labelObject = Gtk.Label(label=('')) self.labelObject.set_size_request(300, -1) self.gui.vbox.pack_start(self.labelObject, expand=False, fill=True, padding=0) # gui geometry px, py = self.window.get_position() pw, ph = self.window.get_size() x, y = self.gui.get_position() if self.plugin.preferences[ 'position'] == InstantsearchPlugin.POSITION_RIGHT: self.gui.move((pw - 300), 0) elif self.plugin.preferences[ 'position'] == InstantsearchPlugin.POSITION_CENTER: self.gui.resize(300, 100) self.gui.move(px + (pw / 2) - 150, py + (ph / 2) - 250) else: raise AttributeError("Instant search: Wrong position preference.") self.gui.show_all() self.labelVar = "" self.timeout = "" self.timeoutOpenPage = None # lastPage = "" # pageTitleOnly = False menu = [] # queryTime = 0 def change(self, _): # widget, event,text if self.timeout: GObject.source_remove(self.timeout) q = self.inputEntry.get_text() # print("Change. {} {}".format(input, self.lastQuery)) if q == self.lastQuery: return if q == self.title_match_char: return if q and q[-1] == "∀": # easter egg: debug option for zim --standalone q = q[:-1] import ipdb ipdb.set_trace() self.state = State.setCurrent(q) if not self.state.isFinished: self.isSubset = True if self.lastQuery and q.startswith( self.lastQuery) else False self.state.checkTitleSearch(self.title_match_char) self.startSearch() else: # search completed before # print("Search already cached.") self.startSearch( ) # update the results in a page has been modified meanwhile (not if something got deleted in the notebook #16 ) self.checkLast() self.soutMenu() self.lastQuery = q def startSearch(self): """ Search string has certainly changed. We search in indexed titles and/or we start zim search. Normally, zim gives 11 points bonus if the search-string appears in the titles. If we are ignoring subpages, the search "foo" will match only page "journal:foo", but not "journal:foo:subpage" (and score of the parent page will get slightly higher by 1.) However, if there are occurences of the string in the fulltext of the subpage, subpage remains in the result, but gets bonus only 2 points (not 11). """ query = self.state.query menu = self.state.menu isInQuery = re.compile( r"(^|:|\s|\()" + query ).search # 'te' matches this page titles: 'test' or 'Journal:test' or 'foo test' or 'foo (test)' if self.isSubset and len(query) < self.start_search_length: # letter(s) was/were added and full search has not yet been activated for path in _MenuItem.titles: if path in self.state.menu and not isInQuery( path.lower()): # 'te' didnt match 'test' etc del menu[path] # we pop out the result else: menu[path].sure = True else: # perform new search in cached_titles _MenuItem.titles = set() found = 0 if self.state.firstSeen: for path, pathLow in self.cached_titles: # quick search in titles if isInQuery( pathLow ): # 'te' matches 'test' or 'Journal:test' etc _MenuItem.titles.add(path) if query in path.lower() and query not in path.lower( ).split(":" )[-1]: # "raz" in "raz:dva", but not in "dva" self.state.menu[":".join(path.split( ":")[:-1])].bonus += 1 # 1 point for subpage menu[path].bonus = -11 menu[ path].score += 10 # 10 points for title (zim default) (so that it gets displayed before search finishes) menu[path].intitle = True menu[path].path = path found += 1 if found >= 10: # we dont want more than 10 results; we would easily match all of the pages break else: menu[path].intitle = False self.processMenu() # show for now results of title search if len(query) >= self.start_search_length: self.timeout = GObject.timeout_add( self.keystroke_delay, self.startZimSearch) # ideal delay between keystrokes def startZimSearch(self): """ Starts search for the input. """ self.timeout = "" self.caret['altPos'] = 0 # possible position of caret - beginning s = '"*{}*"'.format( self.state.query ) if self.plugin.preferences['isWildcarded'] else self.state.query self.queryO = Query( s ) # Xunicode(s) beware when searching for unicode character. Update the row when going to Python3. lastSel = self.selection if self.isSubset and self.state.previous.isFinished else None # it should be quicker to find the string, if we provide this subset from last time (in the case we just added a letter, so that the subset gets smaller) self.selection = SearchSelection(self.window.notebook) state = self.state # this is thread, so that self.state would can before search finishes self.selection.search(self.queryO, selection=lastSel, callback=self._search_callback( self.state.rawQuery)) state.isFinished = True for item in list( state.menu ): # remove all the items that we didnt encounter during the search if not state.menu[item].sure: del state.menu[item] if state == self.state: self.checkLast() self.processMenu(state=state) def checkLast(self): """ opens the page if there is only one option in the menu """ if self.open_when_unique and len(self.state.menu) == 1: self._open_page(Path(self.state.menu.keys()[0]), excludeFromHistory=False) self.close() def _search_callback(self, query): def _search_callback(results, path): if results is not None: self._update_results( results, State.get(query) ) # we finish the search even if another search is running. If we returned False, the search would be cancelled- while Gtk.events_pending(): Gtk.main_iteration_do(blocking=False) return True return _search_callback def _update_results(self, results, state): """ This method may run many times, due to the _update_results, which are updated many times. I may set that _update_results would run only once, but this is nice - the results are appearing one by one. """ changed = False state.lastResults = results for option in results.scores: if state.pageTitleOnly and state.query not in option.name: # hledame jen v nazvu stranky continue if option.name not in state.menu: # new item found if state == self.state and option.name == self.caret[ 'text']: # this is current search self.caret['altPos'] = len( state.menu ) - 1 # karet byl na tehle pozici, pokud se zuzil vyber, budeme vedet, kam karet opravne umistit if option.name not in state.menu or ( state.menu[option.name].bonus < 0 and state.menu[option.name].score == 0): changed = True if not state.menu[option.name].sure: state.menu[option.name].sure = True changed = True state.menu[option.name].score = results.scores[ option] # zaradit mezi moznosti if changed: # we added a page self.processMenu(state=state, sort=False) else: pass def processMenu(self, state=None, sort=True): """ Sort menu and generate items and sout menu. """ if state is None: state = self.state if sort: state.items = sorted( state.menu, reverse=True, key=lambda item: (state.menu[item].intitle, state.menu[item].score + state.menu[ item].bonus, -item.count(":"), item)) else: # when search results are being updated, it's good when the order doesnt change all the time. So that the first result does not become for a while 10th and then become first back. state.items = sorted( state.menu, reverse=True, key=lambda item: (state.menu[item].intitle, -state.menu[item].lastOrder)) state.items = [ item for item in state.items if (state.menu[item].score + state.menu[item].bonus) > 0 ] # i dont know why there are items with 0 score if state == self.state: self.soutMenu() def soutMenu(self, displayImmediately=False): """ Displays menu and handles caret position. """ if self.timeoutOpenPage: GObject.source_remove(self.timeoutOpenPage) self.gui.resize(300, 100) # reset size # treat possible caret deflection if self.caret['pos'] < 0 or self.caret['pos'] > len( self.state.items ) - 1: # place the caret to the beginning or the end of list self.caret['pos'] = self.caret['altPos'] text = "" i = 0 for item in self.state.items: score = self.state.menu[item].score + self.state.menu[item].bonus self.state.menu[item].lastOrder = i if i == self.caret['pos']: self.caret['text'] = item # caret is at this position text += '→ {} ({}) {}\n'.format( item, score, "" if self.state.menu[item].sure else "?") else: try: text += '{} ({}) {}\n'.format( item, score, "" if self.state.menu[item].sure else "?") except: text += "CHYBA\n" text += item[0:-1] + "\n" i += 1 self.labelObject.set_text(text) self.menuPage = Path( self.caret['text'] if len(self.state.items) else self.originalPage) if not displayImmediately: self.timeoutOpenPage = GObject.timeout_add( self.keystroke_delay, self._open_page, self.menuPage) # ideal delay between keystrokes else: self._open_page(self.menuPage) def move(self, widget, event): """ Move caret up and down. Enter to confirm, Esc closes search.""" keyname = Gdk.keyval_name(event.keyval) if keyname == "Up" or keyname == "ISO_Left_Tab": self.caret['pos'] -= 1 self.soutMenu(displayImmediately=False) if keyname == "Down" or keyname == "Tab": self.caret['pos'] += 1 self.soutMenu(displayImmediately=False) if keyname == "KP_Enter" or keyname == "Return": self._open_page(self.menuPage, excludeFromHistory=False) self.close() if keyname == "Escape": self._open_original() # GTK closes the windows itself on Escape, no self.close() needed return ## Safely closes # Xwhen closing directly, Python gave allocation error def close(self): if not self.isClosed: self.isClosed = True self.gui.emit("close") def _open_original(self): self._open_page(Path(self.originalPage)) def _open_page(self, page, excludeFromHistory=True): """ Open page and highlight matches """ self.timeoutOpenPage = None # no delayed page will be open if self.isClosed == True: return if page and page.name and page.name != self.lastPage: self.lastPage = page.name # print("*** HISTORY BEF", self.window.ui.history._history[-3:]) self.window.open_page(page) if excludeFromHistory: # there is no public API, so lets use protected _history instead self.window.history._history.pop() self.window.history._current = len( self.window.history._history) - 1 if not excludeFromHistory and self.window.history.get_current( ).name is not page.name: # we insert the page to the history because it was likely to be just visited and excluded self.window.history.append(page) # Popup find dialog with same query if self.queryO: # and self.queryO.simple_match: string = self.state.query string = string.strip('*') # support partial matches if self.plugin.preferences['highlight_search']: self.window.pageview.show_find(string, highlight=True)