From a1423e485129aa3c302c8feae111337441d6761b Mon Sep 17 00:00:00 2001 From: Naveen M V <30305957+naviji@users.noreply.github.com> Date: Thu, 11 Mar 2021 20:12:39 +0530 Subject: [PATCH] All: Filter "notebook" can now be negated (#4651) --- README.md | 2 +- packages/app-cli/tests/filterParser.js | 2 - .../app-cli/tests/services_SearchFilter.js | 48 +++++++++++++++++++ .../lib/services/searchengine/filterParser.ts | 2 +- .../lib/services/searchengine/queryBuilder.ts | 30 ++++++++---- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e4cdbabe6b..2d4f3cc949 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ You can also use search filters to further restrict the search. |**any:**|Return notes that satisfy any/all of the required conditions. `any:0` is the default, which means all conditions must be satisfied.|`any:1 cat dog` will return notes that have the word `cat` or `dog`.
`any:0 cat dog` will return notes with both the words `cat` and `dog`. | | **title:**
**body:**|Restrict your search to just the title or the body field.|`title:"hello world"` searches for notes whose title contains `hello` and `world`.
`title:hello -body:world` searches for notes whose title contains `hello` and body does not contain `world`. | **tag:** |Restrict the search to the notes with the specified tags.|`tag:office` searches for all notes having tag office.
`tag:office tag:important` searches for all notes having both office and important tags.
`tag:office -tag:spam` searches for notes having tag `office` which do not have tag `spam`.
`any:1 tag:office tag:spam` searches for notes having tag `office` or tag `spam`.
`tag:be*ful` does a search with wildcards.
`tag:*` returns all notes with tags.
`-tag:*` returns all notes without tags.| -| **notebook:** | Restrict the search to the specified notebook(s). It cannot be negated. |`notebook:books` limits the search scope within `books` and all its subnotebooks.
`notebook:wheel*time` does a wildcard search.| +| **notebook:** | Restrict the search to the specified notebook(s). |`notebook:books` limits the search scope within `books` and all its subnotebooks.
`notebook:wheel*time` does a wildcard search.| | **created:**
**updated:** | Searches for notes created/updated on dates specified using YYYYMMDD format. You can also search relative to the current day, week, month, or year. | `created:20201218` will return notes created on or after December 18, 2020.
`-updated:20201218` will return notes updated before December 18, 2020.
`created:20200118 -created:20201215` will return notes created between January 18, 2020, and before December 15, 2020.
`created:202001 -created:202003` will return notes created on or after January and before March 2020.
`updated:1997 -updated:2020` will return all notes updated between the years 1997 and 2019.
`created:day-2` searches for all notes created in the past two days.
`updated:year-0` searches all notes updated in the current year. | **type:** |Restrict the search to either notes or todos. | `type:note` to return all notes
`type:todo` to return all todos | | **iscompleted:** | Restrict the search to either completed or uncompleted todos. | `iscompleted:1` to return all completed todos
`iscompleted:0` to return all uncompleted todos| diff --git a/packages/app-cli/tests/filterParser.js b/packages/app-cli/tests/filterParser.js index 71bcb56a40..3566291c4f 100644 --- a/packages/app-cli/tests/filterParser.js +++ b/packages/app-cli/tests/filterParser.js @@ -152,8 +152,6 @@ describe('filterParser should be correct filter for keyword', () => { searchString = 'iscompleted:blah'; expect(() => filterParser(searchString)).toThrow(new Error('The value of filter "iscompleted" must be "1" or "0"')); - searchString = '-notebook:n1'; - expect(() => filterParser(searchString)).toThrow(new Error('notebook can\'t be negated')); searchString = '-iscompleted:1'; expect(() => filterParser(searchString)).toThrow(new Error('iscompleted can\'t be negated')); diff --git a/packages/app-cli/tests/services_SearchFilter.js b/packages/app-cli/tests/services_SearchFilter.js index 36c29d1213..93a0e6cfe6 100644 --- a/packages/app-cli/tests/services_SearchFilter.js +++ b/packages/app-cli/tests/services_SearchFilter.js @@ -742,4 +742,52 @@ describe('services_SearchFilter', function() { })); + it('should support negating notebooks', (async () => { + + const folder1 = await Folder.save({ title: 'folder1' }); + let n1 = await Note.save({ title: 'task1', body: 'foo', parent_id: folder1.id }); + let n2 = await Note.save({ title: 'task2', body: 'bar', parent_id: folder1.id }); + + + const folder2 = await Folder.save({ title: 'folder2' }); + let n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: folder2.id }); + let n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: folder2.id }); + + + await engine.syncTables(); + + let rows = await engine.search('-notebook:folder1'); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n3.id); + expect(ids(rows)).toContain(n4.id); + + + rows = await engine.search('-notebook:folder2'); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + })); + + it('should support both inclusion and exclusion of notebooks together', (async () => { + + const parentFolder = await Folder.save({ title: 'parent' }); + let n1 = await Note.save({ title: 'task1', body: 'foo', parent_id: parentFolder.id }); + let n2 = await Note.save({ title: 'task2', body: 'bar', parent_id: parentFolder.id }); + + + const subFolder = await Folder.save({ title: 'child', parent_id: parentFolder.id }); + let n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: subFolder.id }); + let n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: subFolder.id }); + + + await engine.syncTables(); + + let rows = await engine.search('notebook:parent -notebook:child'); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + })); + }); diff --git a/packages/lib/services/searchengine/filterParser.ts b/packages/lib/services/searchengine/filterParser.ts index eb1b7d77d7..4c978ed0bc 100644 --- a/packages/lib/services/searchengine/filterParser.ts +++ b/packages/lib/services/searchengine/filterParser.ts @@ -118,7 +118,7 @@ const parseQuery = (query: string): Term[] => { } // validation - let incorrect = result.filter(term => term.name === 'type' || term.name === 'iscompleted' || term.name === 'notebook') + let incorrect = result.filter(term => term.name === 'type' || term.name === 'iscompleted') .find(x => x.negated); if (incorrect) throw new Error(`${incorrect.name} can't be negated`); diff --git a/packages/lib/services/searchengine/queryBuilder.ts b/packages/lib/services/searchengine/queryBuilder.ts index 99dac2edaf..38ccef9a71 100644 --- a/packages/lib/services/searchengine/queryBuilder.ts +++ b/packages/lib/services/searchengine/queryBuilder.ts @@ -21,18 +21,19 @@ enum Requirement { INCLUSION = 'INCLUSION', } -const notebookFilter = (terms: Term[], conditions: string[], params: string[], withs: string[]) => { - const notebooks = terms.filter(x => x.name === 'notebook' && !x.negated).map(x => x.value); +const _notebookFilter = (notebooks: string[], requirement: Requirement, conditions: string[], params: string[], withs: string[]) => { if (notebooks.length === 0) return; const likes = []; for (let i = 0; i < notebooks.length; i++) { likes.push('folders.title LIKE ?'); } + const relevantFolders = likes.join(' OR '); + const viewName = requirement === Requirement.EXCLUSION ? 'notebooks_not_in_scope' : 'notebooks_in_scope'; const withInNotebook = ` - notebooks_in_scope(id) + ${viewName}(id) AS ( SELECT folders.id FROM folders @@ -45,22 +46,35 @@ const notebookFilter = (terms: Term[], conditions: string[], params: string[], w UNION ALL SELECT folders.id FROM folders - JOIN notebooks_in_scope - ON folders.parent_id=notebooks_in_scope.id + JOIN ${viewName} + ON folders.parent_id=${viewName}.id )`; + const where = ` - AND ROWID IN ( + AND ROWID ${requirement === Requirement.EXCLUSION ? 'NOT' : ''} IN ( SELECT notes_normalized.ROWID - FROM notebooks_in_scope + FROM ${viewName} JOIN notes_normalized - ON notebooks_in_scope.id=notes_normalized.parent_id + ON ${viewName}.id=notes_normalized.parent_id )`; + withs.push(withInNotebook); params.push(...notebooks); conditions.push(where); + }; +const notebookFilter = (terms: Term[], conditions: string[], params: string[], withs: string[]) => { + const notebooksToInclude = terms.filter(x => x.name === 'notebook' && !x.negated).map(x => x.value); + _notebookFilter(notebooksToInclude, Requirement.INCLUSION, conditions, params, withs); + + const notebooksToExclude = terms.filter(x => x.name === 'notebook' && x.negated).map(x => x.value); + _notebookFilter(notebooksToExclude, Requirement.EXCLUSION, conditions, params, withs); +}; + + + const getOperator = (requirement: Requirement, relation: Relation): Operation => { if (relation === 'AND' && requirement === 'INCLUSION') { return Operation.INTERSECT; } else { return Operation.UNION; } };