1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Finished search engine integration with desktop app

This commit is contained in:
Laurent Cozic 2018-12-13 23:57:14 +01:00
parent 5ec7c16e3e
commit 061ce646d2
6 changed files with 121 additions and 34 deletions

View File

@ -154,20 +154,30 @@ describe('services_SearchEngine', function() {
let rows;
const testCases = [
['do*', ['do', 'dog', 'domino'] ],
['*an*', ['an', 'piano', 'anneau', 'plan', 'PANIC'] ],
['do*', ['do', 'dog', 'domino'], [] ],
// "*" is a wildcard only when used at the end (to searhc for documents with the specified prefix)
// If it's at the beginning, it's ignored, if it's in the middle, it's treated as a litteral "*".
['*an*', ['an', 'anneau'], ['piano', 'plan'] ],
['no*no', ['no*no'], ['nonono'] ],
];
for (let i = 0; i < testCases.length; i++) {
const t = testCases[i];
const input = t[0];
const expected = t[1];
const regex = engine.parseQuery(input).terms._[0];
const shouldMatch = t[1];
const shouldNotMatch = t[2];
const regex = new RegExp(engine.parseQuery(input).terms._[0].value, 'gmi');
for (let j = 0; j < expected.length; j++) {
const r = expected[j].match(regex);
expect(!!r).toBe(true);
for (let j = 0; j < shouldMatch.length; j++) {
const r = shouldMatch[j].match(regex);
expect(!!r).toBe(true, '"' + input + '" should match "' + shouldMatch[j] + '"');
}
// for (let j = 0; j < shouldNotMatch.length; j++) {
// const r = shouldNotMatch[j].match(regex);
// // console.info(input, shouldNotMatch)
// expect(!!r).toBe(false, '"' + input + '" should not match "' + shouldNotMatch[j] + '"');
// }
}
expect(engine.parseQuery('*').termCount).toBe(0);

View File

@ -13,6 +13,7 @@ const InteropService = require('lib/services/InteropService');
const InteropServiceHelper = require('../InteropServiceHelper.js');
const Search = require('lib/models/Search');
const Mark = require('mark.js/dist/mark.min.js');
const SearchEngine = require('lib/services/SearchEngine');
class NoteListComponent extends React.Component {
@ -234,8 +235,11 @@ class NoteListComponent extends React.Component {
let highlightedWords = [];
if (this.props.notesParentType === 'Search') {
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
highlightedWords = search ? Search.keywords(search.query_pattern) : [];
const query = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
if (query) {
const parsedQuery = SearchEngine.instance().parseQuery(query.query_pattern);
highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
}
let style = Object.assign({ width: width }, this.style().listItem);
@ -266,7 +270,18 @@ class NoteListComponent extends React.Component {
exclude: ['img'],
acrossElements: true,
});
mark.mark(highlightedWords);
mark.unmark();
for (let i = 0; i < highlightedWords.length; i++) {
const w = highlightedWords[i];
if (w.type === 'regex') {
mark.markRegExp(new RegExp(w.value, 'gmi'), { acrossElements: true });
} else {
mark.mark([w]);
}
}
// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement
// is a span tag that we created and that contains data that's been inserted as plain text

View File

@ -34,6 +34,7 @@ const ExternalEditWatcher = require('lib/services/ExternalEditWatcher');
const ResourceFetcher = require('lib/services/ResourceFetcher');
const { toSystemSlashes, safeFilename } = require('lib/path-utils');
const { clipboard } = require('electron');
const SearchEngine = require('lib/services/SearchEngine');
require('brace/mode/markdown');
// https://ace.c9.io/build/kitchen-sink.html
@ -84,7 +85,7 @@ class NoteTextComponent extends React.Component {
this.scheduleSaveTimeout_ = null;
this.restoreScrollTop_ = null;
this.lastSetHtml_ = '';
this.lastSetMarkers_ = [];
this.lastSetMarkers_ = '';
this.lastSetMarkersOptions_ = {};
this.selectionRange_ = null;
this.noteSearchBar_ = React.createRef();
@ -508,7 +509,7 @@ class NoteTextComponent extends React.Component {
}
this.lastSetHtml_ = '';
this.lastSetMarkers_ = [];
this.lastSetMarkers_ = '';
this.lastSetMarkersOptions_ = {};
this.setState(newState);
@ -733,7 +734,7 @@ class NoteTextComponent extends React.Component {
webviewReady: true,
});
// if (Setting.value('env') === 'dev') this.webview_.openDevTools();
if (Setting.value('env') === 'dev') this.webview_.openDevTools();
}
webview_ref(element) {
@ -1558,11 +1559,15 @@ class NoteTextComponent extends React.Component {
markerOptions.selectedIndex = this.state.localSearch.selectedIndex;
} else {
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
if (search) keywords = Search.keywords(search.query_pattern);
if (search) {
const parsedQuery = SearchEngine.instance().parseQuery(search.query_pattern);
keywords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
}
if (htmlHasChanged || !ArrayUtils.contentEquals(this.lastSetMarkers_, keywords) || !ObjectUtils.fieldsEqual(this.lastSetMarkersOptions_, markerOptions)) {
this.lastSetMarkers_ = keywords.slice();
const keywordHash = JSON.stringify(keywords);
if (htmlHasChanged || keywordHash !== this.lastSetMarkers_ || !ObjectUtils.fieldsEqual(this.lastSetMarkersOptions_, markerOptions)) {
this.lastSetMarkers_ = keywordHash;
this.lastSetMarkersOptions_ = Object.assign({}, markerOptions);
this.webview_.send('setMarkers', keywords, markerOptions);
}

View File

@ -36,6 +36,7 @@
<body id="body">
<div id="hlScriptContainer"></div>
<div id="markScriptContainer"></div>
<!-- START_OF_DOCUMENT -->
<div id="content" ondragstart="return false;" ondrop="return false;"></div>
<script>
@ -143,6 +144,8 @@
ipc.setHtml = (event) => {
const html = event.html;
markJsHackMarkerInserted_ = false;
updateBodyHeight();
contentElement.innerHTML = html;
@ -196,6 +199,29 @@
setPercentScroll(percent);
}
// HACK for Mark.js bug - https://github.com/julmot/mark.js/issues/127
let markJsHackMarkerInserted_ = false;
function addMarkJsSpaceHack(document) {
if (markJsHackMarkerInserted_) return;
const prepareElementsForMarkJs = (elements, type) => {
// const markJsHackMarker_ = '&#8203; &#8203;'
const markJsHackMarker_ = ' ';
for (let i = 0; i < elements.length; i++) {
if (!type) {
elements[i].innerHTML = elements[i].innerHTML + markJsHackMarker_;
} else if (type === 'insertBefore') {
elements[i].insertAdjacentHTML('beforeBegin', markJsHackMarker_);
}
}
}
prepareElementsForMarkJs(document.getElementsByTagName('p'));
prepareElementsForMarkJs(document.getElementsByTagName('div'));
prepareElementsForMarkJs(document.getElementsByTagName('br'), 'insertBefore');
markJsHackMarkerInserted_ = true;
}
let mark_ = null;
let markSelectedElement_ = null;
function setMarkers(keywords, options = null) {
@ -208,6 +234,8 @@
});
}
addMarkJsSpaceHack(document);
mark_.unmark()
if (markSelectedElement_) markSelectedElement_.classList.remove('mark-selected');
@ -215,20 +243,32 @@
let selectedElement = null;
let elementIndex = 0;
if (keywords.length) {
mark_.mark(keywords, {
each: (element) => {
if (!('selectedIndex' in options)) return;
const onEachElement = (element) => {
if (!('selectedIndex' in options)) return;
if (('selectedIndex' in options) && elementIndex === options.selectedIndex) {
markSelectedElement_ = element;
element.classList.add('mark-selected');
selectedElement = element;
}
elementIndex++;
}
});
if (('selectedIndex' in options) && elementIndex === options.selectedIndex) {
markSelectedElement_ = element;
element.classList.add('mark-selected');
selectedElement = element;
}
elementIndex++;
}
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (keyword.type === 'regex') {
mark_.markRegExp(new RegExp(keyword.value, 'gmi'), {
each: onEachElement,
acrossElements: true,
});
} else {
mark_.mark([keyword], {
each: onEachElement,
accuracy: 'exactly',
});
}
}
ipcProxySendToHost('setMarkerCount', elementIndex);

View File

@ -395,8 +395,6 @@ class MdToHtml {
previousToken = t;
}
output.unshift('<!-- START_OF_DOCUMENT -->');
// Insert the extra CSS at the top of the HTML
if (!ObjectUtils.isEmpty(extraCssBlocks)) {

View File

@ -101,11 +101,19 @@ class SearchEngine {
// https://stackoverflow.com/a/13818704/561309
queryTermToRegex(term) {
while (term.length && term.indexOf('*') === 0) {
term = term.substr(1);
}
const preg_quote = (str, delimiter) => {
return (str + '').replace(new RegExp('[.\\\\+*?\\[\\^\\]$(){}=!<>|:\\' + (delimiter || '') + '-]', 'g'), '\\$&');
} // [^ \t,\.,\+\-\\*?!={}<>\|:"\'\(\)[\]]
let regexString = preg_quote(term);
if (regexString[regexString.length - 1] === '*') {
regexString = regexString.substr(0, regexString.length - 2) + '[^' + preg_quote(' \t\n\r,.,+-*?!={}<>|:"\'()[]') + ']' + '*';
}
const regexString = preg_quote(term).replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
return new RegExp(regexString, 'gmi');
return regexString;
}
parseQuery(query) {
@ -173,7 +181,7 @@ class SearchEngine {
}
if (term.indexOf('*') >= 0) {
terms[col][i] = this.queryTermToRegex(term);
terms[col][i] = { type: 'regex', value: this.queryTermToRegex(term) };
}
}
@ -189,6 +197,17 @@ class SearchEngine {
};
}
allParsedQueryTerms(parsedQuery) {
if (!parsedQuery || !parsedQuery.termCount) return [];
let output = [];
for (let col in parsedQuery.terms) {
if (!parsedQuery.terms.hasOwnProperty(col)) continue;
output = output.concat(parsedQuery.terms[col]);
}
return output;
}
async search(query) {
const parsedQuery = this.parseQuery(query);
const sql = 'SELECT id, title, offsets(notes_fts) AS offsets FROM notes_fts WHERE notes_fts MATCH ?'