diff --git a/src/public/app/dialogs/add_link.js b/src/public/app/dialogs/add_link.js index 43f67df85..47270c164 100644 --- a/src/public/app/dialogs/add_link.js +++ b/src/public/app/dialogs/add_link.js @@ -75,7 +75,7 @@ function updateTitleFormGroupVisibility() { } $form.on('submit', () => { - const notePath = $autoComplete.getSelectedPath(); + const notePath = $autoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -89,4 +89,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/dialogs/attributes.js b/src/public/app/dialogs/attributes.js index 2cf844420..e4264114c 100644 --- a/src/public/app/dialogs/attributes.js +++ b/src/public/app/dialogs/attributes.js @@ -269,7 +269,7 @@ function initKoPlugins() { init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { noteAutocompleteService.initNoteAutocomplete($(element)); - $(element).setSelectedPath(bindingContext.$data.selectedPath); + $(element).setSelectedNotePath(bindingContext.$data.selectedPath); $(element).on('autocomplete:selected', function (event, suggestion, dataset) { bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : ''; diff --git a/src/public/app/dialogs/clone_to.js b/src/public/app/dialogs/clone_to.js index eab144aac..14e231aae 100644 --- a/src/public/app/dialogs/clone_to.js +++ b/src/public/app/dialogs/clone_to.js @@ -52,7 +52,7 @@ async function cloneNotesTo(notePath) { } $form.on('submit', () => { - const notePath = $noteAutoComplete.getSelectedPath(); + const notePath = $noteAutoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -64,4 +64,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/dialogs/include_note.js b/src/public/app/dialogs/include_note.js index 71d3cb220..1aa2b149e 100644 --- a/src/public/app/dialogs/include_note.js +++ b/src/public/app/dialogs/include_note.js @@ -38,7 +38,7 @@ async function includeNote(notePath) { } $form.on('submit', () => { - const notePath = $autoComplete.getSelectedPath(); + const notePath = $autoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -50,4 +50,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/dialogs/move_to.js b/src/public/app/dialogs/move_to.js index 4dbc6bcca..afd9ceb5f 100644 --- a/src/public/app/dialogs/move_to.js +++ b/src/public/app/dialogs/move_to.js @@ -41,7 +41,7 @@ async function moveNotesTo(parentNoteId) { } $form.on('submit', () => { - const notePath = $noteAutoComplete.getSelectedPath(); + const notePath = $noteAutoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -55,4 +55,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/services/note_autocomplete.js b/src/public/app/services/note_autocomplete.js index 4f6b6a575..c9ae9be8d 100644 --- a/src/public/app/services/note_autocomplete.js +++ b/src/public/app/services/note_autocomplete.js @@ -3,7 +3,7 @@ import appContext from "./app_context.js"; import utils from './utils.js'; // this key needs to have this value so it's hit by the tooltip -const SELECTED_PATH_KEY = "data-note-path"; +const SELECTED_NOTE_PATH_KEY = "data-note-path"; async function autocompleteSource(term, cb) { const result = await server.get('autocomplete' @@ -12,8 +12,8 @@ async function autocompleteSource(term, cb) { if (result.length === 0) { result.push({ - pathTitle: "No results", - path: "" + notePathTitle: "No results", + notePath: "" }); } @@ -25,7 +25,7 @@ function clearText($el) { return; } - $el.setSelectedPath(""); + $el.setSelectedNotePath(""); $el.autocomplete("val", "").trigger('change'); } @@ -34,7 +34,7 @@ function showRecentNotes($el) { return; } - $el.setSelectedPath(""); + $el.setSelectedNotePath(""); $el.autocomplete("val", ""); $el.trigger('focus'); } @@ -91,10 +91,10 @@ function initNoteAutocomplete($el, options) { }, [ { source: autocompleteSource, - displayKey: 'pathTitle', + displayKey: 'notePathTitle', templates: { suggestion: function(suggestion) { - return suggestion.highlightedTitle; + return suggestion.highlightedNotePathTitle; } }, // we can't cache identical searches because notes can be created / renamed, new recent notes can be added @@ -102,7 +102,7 @@ function initNoteAutocomplete($el, options) { } ]); - $el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedPath(suggestion.path)); + $el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedNotePath(suggestion.notePath)); $el.on('autocomplete:closed', () => { if (!$el.val().trim()) { clearText($el); @@ -113,24 +113,24 @@ function initNoteAutocomplete($el, options) { } function init() { - $.fn.getSelectedPath = function () { + $.fn.getSelectedNotePath = function () { if (!$(this).val().trim()) { return ""; } else { - return $(this).attr(SELECTED_PATH_KEY); + return $(this).attr(SELECTED_NOTE_PATH_KEY); } }; - $.fn.setSelectedPath = function (path) { - path = path || ""; + $.fn.setSelectedNotePath = function (notePath) { + notePath = notePath || ""; - $(this).attr(SELECTED_PATH_KEY, path); + $(this).attr(SELECTED_NOTE_PATH_KEY, notePath); $(this) .closest(".input-group") .find(".go-to-selected-note-button") - .toggleClass("disabled", !path.trim()) - .attr(SELECTED_PATH_KEY, path); // we also set attr here so tooltip can be displayed + .toggleClass("disabled", !notePath.trim()) + .attr(SELECTED_NOTE_PATH_KEY, notePath); // we also set attr here so tooltip can be displayed }; } @@ -139,4 +139,4 @@ export default { initNoteAutocomplete, showRecentNotes, init -} \ No newline at end of file +} diff --git a/src/public/app/widgets/promoted_attributes.js b/src/public/app/widgets/promoted_attributes.js index 58e025d07..859ca2291 100644 --- a/src/public/app/widgets/promoted_attributes.js +++ b/src/public/app/widgets/promoted_attributes.js @@ -200,7 +200,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget { this.promotedAttributeChanged(event); }); - $input.setSelectedPath(valueAttr.value); + $input.setSelectedNotePath(valueAttr.value); } else { ws.logError("Unknown attribute type=" + valueAttr.type); @@ -250,7 +250,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget { value = $attr.is(':checked') ? "true" : "false"; } else if ($attr.prop("attribute-type") === "relation") { - const selectedPath = $attr.getSelectedPath(); + const selectedPath = $attr.getSelectedNotePath(); value = selectedPath ? treeService.getNoteIdFromNotePath(selectedPath) : ""; } diff --git a/src/routes/api/autocomplete.js b/src/routes/api/autocomplete.js index a8255b3eb..f5c416eb0 100644 --- a/src/routes/api/autocomplete.js +++ b/src/routes/api/autocomplete.js @@ -18,7 +18,7 @@ async function getAutocomplete(req) { results = await getRecentNotes(activeNoteId); } else { - results = await noteCacheService.findNotesWithFulltext(query); + results = await noteCacheService.findNotesForAutocomplete(query); } const msTaken = Date.now() - timestampStarted; @@ -57,10 +57,9 @@ async function getRecentNotes(activeNoteId) { const title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/')); return { - path: rn.notePath, - pathTitle: title, - highlightedTitle: title, - noteTitle: noteCacheService.getNoteTitleFromPath(rn.notePath) + notePath: rn.notePath, + notePathTitle: title, + highlightedNotePathTitle: utils.escapeHtml(title) }; }); } diff --git a/src/services/note_cache.js b/src/services/note_cache.js index e52619f3d..b35c449e8 100644 --- a/src/services/note_cache.js +++ b/src/services/note_cache.js @@ -371,6 +371,8 @@ async function load() { branches = await getMappedRows(`SELECT branchId, noteId, parentNoteId, prefix FROM branches WHERE isDeleted = 0`, row => new Branch(row)); + attributeIndex = []; + attributes = await getMappedRows(`SELECT attributeId, noteId, type, name, value, isInheritable FROM attributes WHERE isDeleted = 0`, row => new Attribute(row)); @@ -378,17 +380,7 @@ async function load() { loadedPromiseResolve(); } -const expression = { - operator: 'and', - operands: [ - { - operator: 'exists', - fieldName: 'hokus' - } - ] -}; - -class AndOp { +class AndExp { constructor(subExpressions) { this.subExpressions = subExpressions; } @@ -402,7 +394,7 @@ class AndOp { } } -class OrOp { +class OrExp { constructor(subExpressions) { this.subExpressions = subExpressions; } @@ -441,7 +433,7 @@ class NoteSet { } } -class ExistsOp { +class ExistsExp { constructor(attributeType, attributeName) { this.attributeType = attributeType; this.attributeName = attributeName; @@ -469,7 +461,7 @@ class ExistsOp { } } -class EqualsOp { +class EqualsExp { constructor(attributeType, attributeName, attributeValue) { this.attributeType = attributeType; this.attributeName = attributeName; @@ -498,7 +490,7 @@ class EqualsOp { } } -class NoteContentFulltextOp { +class NoteContentFulltextExp { constructor(tokens) { this.tokens = tokens; } @@ -525,7 +517,7 @@ class NoteContentFulltextOp { } } -class NoteCacheFulltextOp { +class NoteCacheFulltextExp { constructor(tokens) { this.tokens = tokens; } @@ -569,7 +561,7 @@ class NoteCacheFulltextOp { } if (foundTokens.length > 0) { - const remainingTokens = tokens.filter(token => !foundTokens.includes(token)); + const remainingTokens = this.tokens.filter(token => !foundTokens.includes(token)); this.searchDownThePath(parentNote, remainingTokens, [note.noteId], resultNoteSet, searchContext); } @@ -651,6 +643,21 @@ class NoteCacheFulltextOp { } } +class SearchResult { + constructor(notePathArray) { + this.notePathArray = notePathArray; + this.notePathTitle = getNoteTitleForPath(notePathArray); + } + + get notePath() { + return this.notePathArray.join('/'); + } + + get noteId() { + return this.notePathArray[this.notePathArray.length - 1]; + } +} + async function findNotesWithExpression(expression) { const hoistedNote = notes[hoistedNoteService.getHoistedNoteId()]; @@ -664,10 +671,27 @@ async function findNotesWithExpression(expression) { noteIdToNotePath: {} }; - expression.execute(allNoteSet, searchContext); + const noteSet = await expression.execute(allNoteSet, searchContext); + + let searchResults = noteSet.notes + .map(note => searchContext.noteIdToNotePath[note.noteId] || getSomePath(note)) + .filter(notePathArray => notePathArray.includes(hoistedNoteService.getHoistedNoteId())) + .map(notePathArray => new SearchResult(notePathArray)); + + // sort results by depth of the note. This is based on the assumption that more important results + // are closer to the note root. + searchResults.sort((a, b) => { + if (a.notePathArray.length === b.notePathArray.length) { + return a.notePathTitle < b.notePathTitle ? -1 : 1; + } + + return a.notePathArray.length < b.notePathArray.length ? -1 : 1; + }); + + return searchResults; } -async function findNotesWithFulltext(query, searchInContent) { +async function findNotesForAutocomplete(query) { if (!query.trim().length) { return []; } @@ -678,74 +702,54 @@ async function findNotesWithFulltext(query, searchInContent) { .split(/[ -]/) .filter(token => token !== '/'); // '/' is used as separator - const cacheResults = findInNoteCache(tokens); + const expression = new NoteCacheFulltextExp(tokens); - const contentResults = searchInContent ? await findInNoteContent(tokens) : []; + let searchResults = await findNotesWithExpression(expression); - let results = cacheResults.concat(contentResults); + searchResults = searchResults.slice(0, 200); - if (hoistedNoteService.getHoistedNoteId() !== 'root') { - results = results.filter(res => res.pathArray.includes(hoistedNoteService.getHoistedNoteId())); - } - - // sort results by depth of the note. This is based on the assumption that more important results - // are closer to the note root. - results.sort((a, b) => { - if (a.pathArray.length === b.pathArray.length) { - return a.title < b.title ? -1 : 1; - } - - return a.pathArray.length < b.pathArray.length ? -1 : 1; - }); - - const apiResults = results.slice(0, 200).map(res => { - const notePath = res.pathArray.join('/'); + highlightSearchResults(searchResults, tokens); + return searchResults.map(result => { return { - noteId: res.noteId, - branchId: res.branchId, - path: notePath, - pathTitle: res.titleArray.join(' / '), - noteTitle: getNoteTitleFromPath(notePath) - }; + notePath: result.notePath, + notePathTitle: result.notePathTitle, + highlightedNotePathTitle: result.highlightedNotePathTitle + } }); - - highlightResults(apiResults, tokens); - - return apiResults; } -function highlightResults(results, allTokens) { +function highlightSearchResults(searchResults, tokens) { // we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks // which would make the resulting HTML string invalid. // { and } are used for marking and tag (to avoid matches on single 'b' character) - allTokens = allTokens.map(token => token.replace('/[<\{\}]/g', '')); + tokens = tokens.map(token => token.replace('/[<\{\}]/g', '')); // sort by the longest so we first highlight longest matches - allTokens.sort((a, b) => a.length > b.length ? -1 : 1); + tokens.sort((a, b) => a.length > b.length ? -1 : 1); - for (const result of results) { + for (const result of searchResults) { const note = notes[result.noteId]; + result.highlightedNotePathTitle = result.notePathTitle; + for (const attr of note.attributes) { - if (allTokens.find(token => attr.name.includes(token) || attr.value.includes(token))) { - result.pathTitle += ` ${formatAttribute(attr)}`; + if (tokens.find(token => attr.name.includes(token) || attr.value.includes(token))) { + result.highlightedNotePathTitle += ` ${formatAttribute(attr)}`; } } - - result.highlightedTitle = result.pathTitle; } - for (const token of allTokens) { + for (const token of tokens) { const tokenRegex = new RegExp("(" + utils.escapeRegExp(token) + ")", "gi"); - for (const result of results) { - result.highlightedTitle = result.highlightedTitle.replace(tokenRegex, "{$1}"); + for (const result of searchResults) { + result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(tokenRegex, "{$1}"); } } - for (const result of results) { - result.highlightedTitle = result.highlightedTitle + for (const result of searchResults) { + result.highlightedNotePathTitle = result.highlightedNotePathTitle .replace(/{/g, "") .replace(/}/g, ""); } @@ -839,17 +843,6 @@ function isInAncestor(noteId, ancestorNoteId) { return false; } -function getNoteTitleFromPath(notePath) { - const pathArr = notePath.split("/"); - - if (pathArr.length === 1) { - return getNoteTitle(pathArr[0], 'root'); - } - else { - return getNoteTitle(pathArr[pathArr.length - 1], pathArr[pathArr.length - 2]); - } -} - function getNoteTitle(childNoteId, parentNoteId) { const childNote = notes[childNoteId]; const parentNote = notes[parentNoteId]; @@ -868,17 +861,17 @@ function getNoteTitle(childNoteId, parentNoteId) { return ((branch && branch.prefix) ? `${branch.prefix} - ` : '') + title; } -function getNoteTitleArrayForPath(path) { +function getNoteTitleArrayForPath(notePathArray) { const titles = []; - if (path[0] === hoistedNoteService.getHoistedNoteId() && path.length === 1) { + if (notePathArray[0] === hoistedNoteService.getHoistedNoteId() && notePathArray.length === 1) { return [ getNoteTitle(hoistedNoteService.getHoistedNoteId()) ]; } let parentNoteId = 'root'; let hoistedNotePassed = false; - for (const noteId of path) { + for (const noteId of notePathArray) { // start collecting path segment titles only after hoisted note if (hoistedNotePassed) { const title = getNoteTitle(noteId, parentNoteId); @@ -896,8 +889,8 @@ function getNoteTitleArrayForPath(path) { return titles; } -function getNoteTitleForPath(path) { - const titles = getNoteTitleArrayForPath(path); +function getNoteTitleForPath(notePathArray) { + const titles = getNoteTitleArrayForPath(notePathArray); return titles.join(' / '); } @@ -1153,10 +1146,9 @@ sqlInit.dbReady.then(() => utils.stopWatch("Note cache load", load)); module.exports = { loadedPromise, - findNotesWithFulltext, + findNotesForAutocomplete, getNotePath, getNoteTitleForPath, - getNoteTitleFromPath, isAvailable, isArchived, isInAncestor,