diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ed6e809..cc0d6fff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ # 2.22 (2021-04-25) * Add a "Give Feedback" button (#551, Rahul Jha). * Test code on macOS (#552, Rahul Jha). +* Support searching for multiple words (#558, Rahul Jha). # 2.21 (2020-12-07) * Update MathJax to version 3 (#515, @dgcampea). diff --git a/TODO.md b/TODO.md index 55c0fbc3e..4ede8bafc 100644 --- a/TODO.md +++ b/TODO.md @@ -10,11 +10,9 @@ please check back with me briefly about its status. - [ ] Update GTK stack for Windows: use MinGW and Python >= 3.6. - [ ] Use separate file for storing CSS to allow users to override styles more easily. - [ ] Make default CSS prettier (see private email exchange). -- [ ] Allow searching for days that contain **multiple** words or tags. - [ ] Check that non-ASCII image filenames work (https://bugs.launchpad.net/bugs/1739701). - [ ] Search and replace (useful for renaming tags and other names). Show "replace" text field after search text has been entered. -- [ ] Add simple way to show all entries: allow searching for whitespace (i.e., don't strip whitespace from search string). - [ ] Copy files and pictures into data subdirectory (#163, #469). - [ ] Require minimum width for calendar panel to avoid hiding it by accident. diff --git a/rednotebook/data.py b/rednotebook/data.py index d4fa6921b..0317d022c 100644 --- a/rednotebook/data.py +++ b/rednotebook/data.py @@ -18,6 +18,7 @@ import datetime import re +import sys TEXT_RESULT_LENGTH = 42 @@ -178,14 +179,14 @@ def get_words(self, with_special_chars=False): def get_number_of_words(self): return len(self.get_words(with_special_chars=True)) - def search(self, text, tags): + def search(self, words, tags): """ This method is only called for days that have all given tags. Search in date first, then in the text, then in the tags. Uses case-insensitive search. """ results = [] - if not text: + if not words: # Only add text result once for all tags. add_text_to_results = False for day_tag, entries in self.get_category_content_pairs().items(): @@ -200,41 +201,52 @@ def search(self, text, tags): add_text_to_results = True if add_text_to_results: results.append(get_text_with_dots(self.text, 0, TEXT_RESULT_LENGTH)) - elif text in str(self): - # Date contains searched text. - results.append(get_text_with_dots(self.text, 0, TEXT_RESULT_LENGTH)) else: - text_result = self.search_in_text(text) + non_date_words = [word for word in words if word not in str(self)] + words_contain_date = len(non_date_words) != len(words) + if words_contain_date: + results.append(get_text_with_dots(self.text, 0, TEXT_RESULT_LENGTH)) + # If all the words matched agains the date, return. + if not non_date_words: + return str(self), results + + text_result = self.search_in_text(non_date_words) if text_result: results.append(text_result) - results.extend(self.search_in_categories(text)) + results.extend(self.search_in_categories(non_date_words)) return str(self), results - def search_in_text(self, search_text): - occurence = self.text.upper().find(search_text.upper()) - - # Check if search_text is in text - if occurence < 0: - return None - - found_text = self.text[occurence : occurence + len(search_text)] - result_text = get_text_with_dots( - self.text, occurence, occurence + len(search_text), found_text + def search_in_text(self, words): + """ + If all words are in the text, return a suitable text substring. + Otherwise, return None. + """ + match_word, smallest_index = None, sys.maxsize + for word in words: + index = self.text.lower().find(word.lower()) + if index < 0: + return + if index < smallest_index: + match_word, smallest_index = word, index + + found_text = self.text[smallest_index : smallest_index + len(match_word)] + return get_text_with_dots( + self.text, smallest_index, smallest_index + len(match_word), found_text ) - return result_text - def search_in_categories(self, text): + def search_in_categories(self, words): results = [] - for category, content in self.get_category_content_pairs().items(): - if content: - if text.upper() in category.upper(): - results.extend(content) - else: - results.extend( - entry for entry in content if text.upper() in entry.upper() - ) - elif text.upper() in category.upper(): - results.append(category) + for word in words: + for category, content in self.get_category_content_pairs().items(): + if content: + if word.upper() in category.upper(): + results.extend(content) + else: + results.extend( + entry for entry in content if word.upper() in entry.upper() + ) + elif word.upper() in category.upper(): + results.append(category) return results def __str__(self): diff --git a/rednotebook/gui/browser.py b/rednotebook/gui/browser.py index 64cad2c59..924c30c55 100644 --- a/rednotebook/gui/browser.py +++ b/rednotebook/gui/browser.py @@ -69,7 +69,9 @@ def highlight(self, search_text): # Tell the webview which text to highlight after the html is loaded self.search_text = search_text self.get_find_controller().search( - self.search_text, WebKit2.FindOptions.CASE_INSENSITIVE, MAX_HITS + self.search_text, + WebKit2.FindOptions.CASE_INSENSITIVE | WebKit2.FindOptions.WRAP_AROUND, + MAX_HITS, ) def on_load_changed(self, webview, event): diff --git a/rednotebook/gui/editor.py b/rednotebook/gui/editor.py index 7aad8f487..3c76671d5 100644 --- a/rednotebook/gui/editor.py +++ b/rednotebook/gui/editor.py @@ -44,7 +44,7 @@ def __init__(self, day_text_view): self._connect_undo_signals() - self.search_text = "" + self.search_words = [] # spell checker self._spell_checker = None @@ -121,8 +121,8 @@ def replace_selection_and_highlight(self, p1, p2, p3): end.backward_chars(len(p3)) self.day_text_buffer.select_range(start, end) - def highlight(self, text): - self.search_text = text + def highlight(self, words): + self.search_words = words buf = self.day_text_buffer # Clear previous highlighting @@ -131,8 +131,8 @@ def highlight(self, text): buf.remove_tag_by_name("highlighter", start, end) # Highlight matches - if text: - for match_start, match_end in self.iter_search_matches(text): + for word in words: + for match_start, match_end in self.iter_search_matches(word): buf.apply_tag_by_name("highlighter", match_start, match_end) search_flags = ( diff --git a/rednotebook/gui/main_window.py b/rednotebook/gui/main_window.py index 38396d720..fc8f3eff5 100644 --- a/rednotebook/gui/main_window.py +++ b/rednotebook/gui/main_window.py @@ -724,9 +724,20 @@ def set_date(self, new_month, new_date, day): def get_day_text(self): return self.day_text_field.get_text() - def highlight_text(self, search_text): - self.html_editor.highlight(search_text) - self.day_text_field.highlight(search_text) + def highlight_text(self, search_words): + self.day_text_field.highlight(search_words) + + # The HTML view can only highlight one match, so we search for a match ourselves + # and highlight the last match, since it's what the user is currently typing. + day_text = self.day_text_field.get_text() + + def get_last_match(): + for word in reversed(search_words): + if word.lower() in day_text.lower(): + return word + return "" + + self.html_editor.highlight(get_last_match()) def show_message(self, title, msg, msg_type): if msg_type == Gtk.MessageType.ERROR: @@ -814,6 +825,18 @@ def _get_buffer(self, key, text): def _get_buffer_for_day(self, day): return self._get_buffer(day.date, day.text) + def scroll_to_non_date_text(self, words): + """ + Find the first non-date word in words, and pass it on to + `Editor.scroll_to_text`. + """ + for word in words: + # If word matches date, it probably is not present in the text. + if word in str(self.day): + pass + else: + super().scroll_to_text(word) + def show_day(self, new_day): # Show new day self.day = new_day @@ -821,10 +844,10 @@ def show_day(self, new_day): self.replace_buffer(buf) self.day_text_view.grab_focus() - if self.search_text: + if self.search_words: # If a search is currently made, scroll to the text and return. - GObject.idle_add(self.scroll_to_text, self.search_text) - GObject.idle_add(self.highlight, self.search_text) + GObject.idle_add(self.scroll_to_non_date_text, self.search_words) + GObject.idle_add(self.highlight, self.search_words) return def show_template(self, title, text): diff --git a/rednotebook/gui/search.py b/rednotebook/gui/search.py index 6d9e75629..a05d390bd 100644 --- a/rednotebook/gui/search.py +++ b/rednotebook/gui/search.py @@ -18,7 +18,7 @@ from xml.sax.saxutils import escape -from gi.repository import GObject, Gtk +from gi.repository import Gtk from rednotebook.gui.customwidgets import CustomComboBoxEntry, CustomListView from rednotebook.util import dates @@ -52,26 +52,21 @@ def on_entry_activated(self, entry): self.search(search_text) def search(self, search_text): - tags = [] - queries = [] + tags, words = [], [] for part in search_text.split(): if part.startswith("#"): tags.append(part.lstrip("#").lower()) else: - queries.append(part) + words.append(part) - search_text = " ".join(queries) - - # Highlight all occurences in the current day's text - self.main_window.highlight_text(search_text) + # Highlight all occurrences in the current day's text. + self.main_window.highlight_text(words) # Scroll to query. - if search_text: - GObject.idle_add( - self.main_window.day_text_field.scroll_to_text, search_text - ) + if words: + self.main_window.day_text_field.scroll_to_non_date_text(tags + words) - self.main_window.search_tree_view.update_data(search_text, tags) + self.main_window.search_tree_view.update_data(words, tags) # Without the following, showing the search results sometimes lets the # search entry lose focus and search phrases are added to a day's text. @@ -89,10 +84,10 @@ def __init__(self, main_window, always_show_results): self.connect("cursor_changed", self.on_cursor_changed) - def update_data(self, search_text, tags): + def update_data(self, words, tags): self.tree_store.clear() - if not self.always_show_results and not tags and not search_text: + if not self.always_show_results and not tags and not words: self.main_window.cloud.show() self.main_window.search_scroll.hide() return @@ -100,7 +95,7 @@ def update_data(self, search_text, tags): self.main_window.cloud.hide() self.main_window.search_scroll.show() - for date_string, entries in self.journal.search(search_text, tags): + for date_string, entries in self.journal.search(words, tags): for entry in entries: entry = escape(entry) entry = entry.replace("STARTBOLD", "").replace("ENDBOLD", "") diff --git a/rednotebook/journal.py b/rednotebook/journal.py index 10ee94786..852f54281 100644 --- a/rednotebook/journal.py +++ b/rednotebook/journal.py @@ -516,10 +516,10 @@ def get_entries(self, category): entries |= set(day.get_entries(category)) return sorted(entries) - def search(self, text, tags): + def search(self, words, tags): results = [] for day in reversed(self.get_days_with_tags(tags)): - results.append(day.search(text, tags)) + results.append(day.search(words, tags)) return results def get_days_with_tags(self, tags):