Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: Resolves #10549: Add exact search for title and body of notes #10991

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,7 @@ packages/lib/services/search/filterParser.js
packages/lib/services/search/gotoAnythingStyleQuery.test.js
packages/lib/services/search/gotoAnythingStyleQuery.js
packages/lib/services/search/queryBuilder.js
packages/lib/services/search/types.js
packages/lib/services/share/ShareService.test.js
packages/lib/services/share/ShareService.js
packages/lib/services/share/invitationRespond.js
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,7 @@ packages/lib/services/search/filterParser.js
packages/lib/services/search/gotoAnythingStyleQuery.test.js
packages/lib/services/search/gotoAnythingStyleQuery.js
packages/lib/services/search/queryBuilder.js
packages/lib/services/search/types.js
packages/lib/services/share/ShareService.test.js
packages/lib/services/share/ShareService.js
packages/lib/services/share/invitationRespond.js
Expand Down
3 changes: 2 additions & 1 deletion packages/app-desktop/plugins/GotoAnything.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import Resource from '@joplin/lib/models/Resource';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import Dialog from '../gui/Dialog';
import { SearchType } from '@joplin/lib/services/search/types';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';

const logger = Logger.create('GotoAnything');
Expand Down Expand Up @@ -267,7 +268,7 @@ class DialogComponent extends React.PureComponent<Props, State> {
}

public async keywords(searchQuery: string) {
const parsedQuery = await SearchEngine.instance().parseQuery(searchQuery);
const parsedQuery = await SearchEngine.instance().parseQuery(searchQuery, SearchType.Auto);
return SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/app-mobile/components/screens/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import SearchEngineUtils from '@joplin/lib/services/search/SearchEngineUtils';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { AppState } from '../../utils/types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { SearchType } from '@joplin/lib/services/search/types';

class SearchScreenComponent extends BaseScreenComponent {

Expand Down Expand Up @@ -118,7 +119,7 @@ class SearchScreenComponent extends BaseScreenComponent {

if (!this.isMounted_) return;

const parsedQuery = await SearchEngine.instance().parseQuery(query);
const parsedQuery = await SearchEngine.instance().parseQuery(query, SearchType.Auto);
const highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);

this.props.dispatch({
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/BaseApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { join } from 'path';
import processStartFlags from './utils/processStartFlags';
import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems';
import determineProfileAndBaseDir from './determineBaseAppDirs';
import { SearchType } from './services/search/types';

const appLogger: LoggerWrapper = Logger.create('App');

Expand Down Expand Up @@ -253,7 +254,7 @@ export default class BaseApplication {
const response = await SearchEngineUtils.notesForQuery(search.query_pattern, true, { appendWildCards: true });
notes = response.notes;
searchResults = response.results;
const parsedQuery = await SearchEngine.instance().parseQuery(search.query_pattern);
const parsedQuery = await SearchEngine.instance().parseQuery(search.query_pattern, SearchType.Auto);
highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
} else if (parentType === BaseModel.TYPE_SMART_FILTER) {
notes = await Note.previews(parentId, options);
Expand Down
41 changes: 40 additions & 1 deletion packages/lib/services/search/SearchEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SearchEngine from './SearchEngine';
import Note from '../../models/Note';
import ItemChange from '../../models/ItemChange';
import Setting from '../../models/Setting';
import { SearchType } from './types';

let engine: SearchEngine = null;

Expand Down Expand Up @@ -478,7 +479,7 @@ describe('services/SearchEngine', () => {
const t = testCases[i];
const input = t[0];
const expected = t[1];
const actual = await engine.parseQuery(input);
const actual = await engine.parseQuery(input, SearchType.Auto);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const _Values = actual.terms._ ? actual.terms._.map((v: any) => v.value) : undefined;
Expand Down Expand Up @@ -563,6 +564,44 @@ describe('services/SearchEngine', () => {
expect(rows.length).toBe(1);
}));

it.each([
['"2024-05-06"', '\\"2024-05-06\\"'],
['2024-05-06', '2024-05-06'],
['', ''],
['a b c', 'a b c'],
['"b c"', '\\"b c\\"'],
['""', '\\"\\"'],
['hello world"', 'hello world"'],
['"hello world', '"hello world'],
])('should escape double quoted strings', (input: string, output: string) => {
const engine = new SearchEngine();
expect(engine.escapeDoubleQuotes(input, SearchType.Auto)).toEqual(output);
});

it('should not escape double quote value when it is non latin search', () => {
const engine = new SearchEngine();
const input = '"hello world"';
expect(engine.escapeDoubleQuotes(input, SearchType.Nonlatin)).toEqual(input);
});

it('should allow exact search with double quotes when searching for title or body', (async () => {
await Note.save({ title: '2024-05-06' });
await Note.save({ title: '2024-06-05' });
await Note.save({ title: 'a', body: '2023-01-02' });
await Note.save({ title: 'b', body: '2023-02-01' });

await engine.syncTables();

expect((await engine.search('title: 2024-05-06', { searchType: SearchEngine.SEARCH_TYPE_FTS })).length).toBe(2);
expect((await engine.search('title: "2024-05-06"', { searchType: SearchEngine.SEARCH_TYPE_FTS })).length).toBe(1);
expect((await engine.search('title: "2024-06-05"', { searchType: SearchEngine.SEARCH_TYPE_FTS })).length).toBe(1);

expect((await engine.search('body: 2023-01-02', { searchType: SearchEngine.SEARCH_TYPE_FTS })).length).toBe(2);
expect((await engine.search('body: "2023-01-02"', { searchType: SearchEngine.SEARCH_TYPE_FTS })).length).toBe(1);
expect((await engine.search('body: "2023-02-01"', { searchType: SearchEngine.SEARCH_TYPE_FTS })).length).toBe(1);
}));


// Disabled for now:
// https://github.com/laurent22/joplin/issues/9769#issuecomment-1912459744

Expand Down
33 changes: 21 additions & 12 deletions packages/lib/services/search/SearchEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,10 @@ import BaseItem from '../../models/BaseItem';
import { isCallbackUrl, parseCallbackUrl } from '../../callbackUrlUtils';
import replaceUnsupportedCharacters from '../../utils/replaceUnsupportedCharacters';
import { htmlentitiesDecode } from '@joplin/utils/html';
import { SearchType } from './types';
const { sprintf } = require('sprintf-js');
import { pregQuote, scriptType, removeDiacritics } from '../../string-utils';

enum SearchType {
Auto = 'auto',
Basic = 'basic',
Nonlatin = 'nonlatin',
Fts = 'fts',
}

interface SearchOptions {
searchType?: SearchType;

Expand Down Expand Up @@ -68,6 +62,8 @@ export interface Terms {
body: (string | ComplexTerm)[];
}

const quoted = (s: string) => s.startsWith('"') && s.endsWith('"');

export default class SearchEngine {

public static instance_: SearchEngine = null;
Expand Down Expand Up @@ -521,9 +517,9 @@ export default class SearchEngine {
return regexString;
}

public async parseQuery(query: string) {
public async parseQuery(query: string, searchType: SearchType) {

const trimQuotes = (str: string) => str.startsWith('"') ? str.substr(1, str.length - 2) : str;
const trimQuotes = (str: string) => quoted(str) ? str.substr(1, str.length - 2) : str;

let allTerms: Term[] = [];

Expand Down Expand Up @@ -591,9 +587,15 @@ export default class SearchEngine {
//

allTerms = allTerms.map(x => {
if (x.name === 'text' || x.name === 'title' || x.name === 'body') {
if (x.name === 'text') {
return { ...x, value: this.normalizeText_(x.value) };
}
if (x.name === 'title' || x.name === 'body') {
const escaped = this.escapeDoubleQuotes(x.value, searchType);
const newValue = this.normalizeText_(escaped);

return { ...x, value: newValue };
}
return x;
});

Expand All @@ -606,6 +608,13 @@ export default class SearchEngine {
};
}

public escapeDoubleQuotes(value: string, searchType: SearchType) {
if (searchType === SearchType.Nonlatin) return value;
if (!quoted(value)) return value;

return `\\${value.slice(0, -1)}\\"`;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public allParsedQueryTerms(parsedQuery: any) {
if (!parsedQuery || !parsedQuery.termCount) return [];
Expand Down Expand Up @@ -658,7 +667,7 @@ export default class SearchEngine {

public async basicSearch(query: string) {
query = query.replace(/\*/, '');
const parsedQuery = await this.parseQuery(query);
const parsedQuery = await this.parseQuery(query, SearchType.Basic);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const searchOptions: any = {};

Expand Down Expand Up @@ -757,7 +766,7 @@ export default class SearchEngine {
};

const searchType = this.determineSearchType_(searchString, options.searchType);
const parsedQuery = await this.parseQuery(searchString);
const parsedQuery = await this.parseQuery(searchString, searchType);

let rows: ProcessResultsRow[] = [];

Expand Down
25 changes: 19 additions & 6 deletions packages/lib/services/search/filterParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,30 @@ describe('filterParser should be correct filter for keyword', () => {

it('title with multiple words', () => {
const searchString = 'title:"word1 word2" body:testBody';
expect(filterParser(searchString)).toContainEqual(makeTerm('title', 'word1', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('title', 'word2', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('title', '"word1 word2"', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', 'testBody', false));
});

it('body with multiple words', () => {
const searchString = 'title:testTitle body:"word1 word2"';
expect(filterParser(searchString)).toContainEqual(makeTerm('title', 'testTitle', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', 'word1', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', 'word2', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', '"word1 word2"', false));
});

it('body with phrase and multiple words', () => {
const searchString = 'title:testTitle body:"word1 word2" word3 word4';
expect(filterParser(searchString)).toContainEqual(makeTerm('title', 'testTitle', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', '"word1 word2"', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('text', '"word3"', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('text', '"word4"', false));
});

it('body with phrase and multiple words and negatives', () => {
const searchString = '-word4 title:testTitle body:"word1 word2" word3 ';
expect(filterParser(searchString)).toContainEqual(makeTerm('title', 'testTitle', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', '"word1 word2"', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('text', '"word3"', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('text', '"word4"', true));
});

it('single word text', () => {
Expand All @@ -71,8 +85,7 @@ describe('filterParser should be correct filter for keyword', () => {

it('multi word body', () => {
const searchString = 'body:"foo bar"';
expect(filterParser(searchString)).toContainEqual(makeTerm('body', 'foo', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', 'bar', false));
expect(filterParser(searchString)).toContainEqual(makeTerm('body', '"foo bar"', false));
});

it('negated tag queries', () => {
Expand Down
16 changes: 9 additions & 7 deletions packages/lib/services/search/filterParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,15 @@ const parseQuery = (query: string): Term[] => {
if (name === 'tag' || name === 'notebook' || name === 'resource' || name === 'sourceurl') {
result.push({ name, value: trimQuotes(value.replace(/[*]/g, '%')), negated }); // for wildcard search
} else if (name === 'title' || name === 'body') {
// Trim quotes since we don't support phrase query here
// eg. Split title:"hello world" to title:hello title:world
const values = trimQuotes(value).split(/[\s-_]+/);
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
values.forEach(value => {
result.push({ name, value, negated, wildcard: value.indexOf('*') >= 0 });
});
// Exact search
if (quoted(value)) {
result.push({ name, value: value, negated, wildcard: value.indexOf('*') >= 0 });
} else {
value.split(/[\s-_]+/)
.map(word => {
result.push({ name, value: word, negated, wildcard: word.indexOf('*') >= 0 });
});
}
} else {
result.push({ name, value, negated });
}
Expand Down
7 changes: 7 additions & 0 deletions packages/lib/services/search/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// eslint-disable-next-line import/prefer-default-export
export enum SearchType {
Auto = 'auto',
Basic = 'basic',
Nonlatin = 'nonlatin',
Fts = 'fts',
}
Loading