from whoosh.fields import ID, STORED, TEXT, SchemaClass
from whoosh.index import Index
from whoosh.qparser import MultifieldParser
+from whoosh.query import Every
from whoosh.searching import Hit
from whoosh.support.charset import accent_map
@title.setter
def title(self, new_title):
+ new_title = new_title.strip()
if not self._is_valid_title(new_title):
raise InvalidTitleError
new_filepath = os.path.join(
class SearchResult(Note):
def __init__(self, flatnotes: "Flatnotes", hit: Hit) -> None:
super().__init__(flatnotes, strip_ext(hit["filename"]))
- self._title_highlights = hit.highlights("title", text=self.title)
- self._content_highlights = hit.highlights(
- "content",
- text=self.content,
+
+ self._matched_fields = self._get_matched_fields(hit.matched_terms())
+
+ self._title_highlights = (
+ hit.highlights("title", text=self.title)
+ if "title" in self._matched_fields
+ else None
+ )
+ self._content_highlights = (
+ hit.highlights(
+ "content",
+ text=self.content,
+ )
+ if "content" in self._matched_fields
+ else None
+ )
+ self._tag_matches = (
+ [field[1] for field in hit.matched_terms() if field[0] == "tags"]
+ if "tags" in self._matched_fields
+ else None
)
@property
def content_highlights(self):
return self._content_highlights
+ @property
+ def tag_matches(self):
+ return self._tag_matches
+
+ @staticmethod
+ def _get_matched_fields(matched_terms):
+ """Return a set of matched fields from a set of ('field', 'term') "
+ "tuples generated by whoosh.searching.Hit.matched_terms()."""
+ return set([matched_term[0] for matched_term in matched_terms])
+
class Flatnotes(object):
TAG_SECTION_RE = re.compile(r"(?:\s+#\w+)+$")
"""Search the index for the given term."""
self.update_index_debounced()
with self.index.searcher() as searcher:
- query = MultifieldParser(
- ["title", "content", "tags"], self.index.schema
- ).parse(term)
- results = searcher.search(query, limit=None)
+ if term == "*":
+ query = Every()
+ else:
+ query = MultifieldParser(
+ ["title", "content", "tags"], self.index.schema
+ ).parse(term)
+ results = searcher.search(query, limit=None, terms=True)
return tuple(SearchResult(self, hit) for hit in results)
-from typing import Dict, Optional
+from typing import Dict, List, Optional
from helpers import CamelCaseBaseModel
last_modified: int
title_highlights: Optional[str]
content_highlights: Optional[str]
+ tag_matches: Optional[List[str]]
@classmethod
def dump(self, search_result: SearchResult) -> Dict:
"lastModified": search_result.last_modified,
"titleHighlights": search_result.title_highlights,
"contentHighlights": search_result.content_highlights,
+ "tagMatches": search_result.tag_matches,
}
}
class SearchResult extends Note {
- constructor(title, lastModified, titleHighlights, contentHighlights) {
+ constructor(title, lastModified, titleHighlights, contentHighlights, tagMatches) {
super(title, lastModified);
this.titleHighlights = titleHighlights;
this.contentHighlights = contentHighlights;
+ this.tagMatches = tagMatches;
}
get titleHighlightsOrTitle() {
<!-- Search Results Loaded -->
<div v-else>
+ <button type="button" class="bttn mb-3" @click="toggleHighlights">
+ <b-icon :icon="showHighlights ? 'eye-slash' : 'eye'"></b-icon>
+ {{ showHighlights ? "Hide" : "Show" }} Highlights
+ </button>
<div
v-for="result in searchResults"
:key="result.title"
- class="bttn result mb-3"
+ class="bttn result mb-2"
>
<a :href="result.href" @click.prevent="openNote(result.href)">
+ <div class="d-flex align-items-center">
+ <p
+ class="result-title"
+ v-html="
+ showHighlights ? result.titleHighlightsOrTitle : result.title
+ "
+ ></p>
+ </div>
<p
- class="font-weight-bold mb-0"
- v-html="result.titleHighlightsOrTitle"
+ v-show="showHighlights"
+ class="result-contents"
+ v-html="result.contentHighlights"
></p>
- <p class="result-contents" v-html="result.contentHighlights"></p>
+ <div v-show="showHighlights">
+ <span v-for="tag in result.tagMatches" :key="tag" class="tag mr-2"
+ >#{{ tag }}</span
+ >
+ </div>
</a>
</div>
</div>
margin: 0;
}
+.result-title {
+ color: $text;
+}
+
.result-contents {
color: $muted-text;
}
font-weight: bold;
color: $logo-key-colour;
}
+
+.tag {
+ color: white;
+ font-size: 14px;
+ background-color: $logo-key-colour;
+ padding: 2px 6px;
+ border-radius: 4px;
+}
</style>
<script>
+import * as constants from "../constants";
+import * as helpers from "../helpers";
+
import EventBus from "../eventBus";
import LoadingIndicator from "./LoadingIndicator";
import { SearchResult } from "../classes";
searchFailedMessage: "Failed to load Search Results",
searchFailedIcon: null,
searchResults: null,
+ showHighlights: true,
};
},
result.title,
result.lastModified,
result.titleHighlights,
- result.contentHighlights
+ result.contentHighlights,
+ result.tagMatches
)
);
});
EventBus.$emit("navigate", href);
},
+ toggleHighlights: function () {
+ this.showHighlights = !this.showHighlights;
+ helpers.setSearchParam(
+ constants.params.showHighlights,
+ this.showHighlights
+ );
+ },
+
init: function () {
this.getSearchResults();
},
created: function () {
this.init();
+
+ let showHighlightsParam = helpers.getSearchParam(
+ constants.params.showHighlights
+ );
+ if (typeof showHighlightsParam == "string") {
+ this.showHighlights = showHighlightsParam === "true";
+ } else {
+ this.showHighlights = true;
+ }
},
};
</script>
};
// Params
-export const params = { searchTerm: "term", redirect: "redirect" };
+export const params = {
+ searchTerm: "term",
+ redirect: "redirect",
+ showHighlights: "showHighlights",
+};
// Other
export const alphabet = [
let urlSearchParams = new URLSearchParams(window.location.search);
return urlSearchParams.get(paramName);
}
+
+export function setSearchParam(paramName, value) {
+ let url = new URL(window.location.href);
+ let urlSearchParams = new URLSearchParams(url.search);
+ urlSearchParams.set(paramName, value);
+ url.search = urlSearchParams.toString();
+ window.history.replaceState({}, "", url.toString());
+}