diff --git a/.gitignore b/.gitignore index 7af424c50f3..435c70e0870 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ yarn-error.log junit.xml /src/mirador-viewer/config.local.js +.vscode \ No newline at end of file diff --git a/cypress/e2e/search-navbar.cy.ts b/cypress/e2e/search-navbar.cy.ts index 0613e5e7124..012182aeb6a 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -17,7 +17,7 @@ describe('Search from Navigation Bar', () => { // NOTE: these tests currently assume this query will return results! const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); - it('should go to search page with correct query if submitted (from home)', () => { + it.only('should go to search page with correct query if submitted (from home)', () => { cy.visit('/'); // This is the GET command that will actually run the search cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); @@ -26,6 +26,8 @@ describe('Search from Navigation Bar', () => { page.submitQueryByPressingEnter(); // New URL should include query param cy.url().should('include', 'query='.concat(query)); + // New URL should NOT include the extended param + cy.url().should('not.include', 'extended=true'); // Wait for search results to come back from the above GET command cy.wait('@search-results'); // At least one search result should be displayed diff --git a/karma.conf.js b/karma.conf.js index f96558bfaff..cc4e2a0b0e4 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,7 @@ module.exports = function (config) { ], client: { clearContext: false, // leave Jasmine Spec Runner output visible in browser - captureConsole: false, + captureConsole: false, // Set to true to get output from tests in the terminal jasmine: { failSpecWithNoExpectations: true } diff --git a/src/app/core/shared/search/models/paginated-search-options.model.ts b/src/app/core/shared/search/models/paginated-search-options.model.ts index 8958cfb3ab0..5c4af1a6c28 100644 --- a/src/app/core/shared/search/models/paginated-search-options.model.ts +++ b/src/app/core/shared/search/models/paginated-search-options.model.ts @@ -14,7 +14,7 @@ export class PaginatedSearchOptions extends SearchOptions { pagination?: PaginationComponentOptions; sort?: SortOptions; - constructor(options: {configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions, view?: ViewMode}) { + constructor(options: {configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions, view?: ViewMode, expert?: boolean}) { super(options); this.pagination = options.pagination; this.sort = options.sort; diff --git a/src/app/core/shared/search/models/search-options.model.spec.ts b/src/app/core/shared/search/models/search-options.model.spec.ts index 30549c59846..17f0386704d 100644 --- a/src/app/core/shared/search/models/search-options.model.spec.ts +++ b/src/app/core/shared/search/models/search-options.model.spec.ts @@ -1,6 +1,101 @@ import { DSpaceObjectType } from '../../dspace-object-type.model'; import { SearchFilter } from './search-filter.model'; -import { SearchOptions } from './search-options.model'; +import { + escapeLuceneSpecialChars, + SearchOptions, +} from './search-options.model'; + +describe('escapeLuceneSpecialChars', () => { + + it('should return plain text unchanged', () => { + expect(escapeLuceneSpecialChars('hello world')).toEqual('hello world'); + }); + + it('should escape +', () => { + expect(escapeLuceneSpecialChars('a+b')).toEqual('a\\+b'); + }); + + it('should escape -', () => { + expect(escapeLuceneSpecialChars('a-b')).toEqual('a\\-b'); + }); + + it('should escape & (and thereby &&)', () => { + expect(escapeLuceneSpecialChars('a&&b')).toEqual('a\\&\\&b'); + }); + + it('should escape | (and thereby ||)', () => { + expect(escapeLuceneSpecialChars('a||b')).toEqual('a\\|\\|b'); + }); + + it('should escape !', () => { + expect(escapeLuceneSpecialChars('!a')).toEqual('\\!a'); + }); + + it('should escape (', () => { + expect(escapeLuceneSpecialChars('(a')).toEqual('\\(a'); + }); + + it('should escape )', () => { + expect(escapeLuceneSpecialChars('a)')).toEqual('a\\)'); + }); + + it('should escape {', () => { + expect(escapeLuceneSpecialChars('{a')).toEqual('\\{a'); + }); + + it('should escape }', () => { + expect(escapeLuceneSpecialChars('a}')).toEqual('a\\}'); + }); + + it('should escape [', () => { + expect(escapeLuceneSpecialChars('[a')).toEqual('\\[a'); + }); + + it('should escape ]', () => { + expect(escapeLuceneSpecialChars('a]')).toEqual('a\\]'); + }); + + it('should escape ^', () => { + expect(escapeLuceneSpecialChars('^a')).toEqual('\\^a'); + }); + + it('should escape "', () => { + expect(escapeLuceneSpecialChars('"a"')).toEqual('\\"a\\"'); + }); + + it('should escape ~', () => { + expect(escapeLuceneSpecialChars('a~')).toEqual('a\\~'); + }); + + it('should escape *', () => { + expect(escapeLuceneSpecialChars('a*')).toEqual('a\\*'); + }); + + it('should escape ?', () => { + expect(escapeLuceneSpecialChars('a?')).toEqual('a\\?'); + }); + + it('should escape :', () => { + expect(escapeLuceneSpecialChars('title:foo')).toEqual('title\\:foo'); + }); + + it('should escape \\', () => { + expect(escapeLuceneSpecialChars('a\\b')).toEqual('a\\\\b'); + }); + + it('should escape /', () => { + expect(escapeLuceneSpecialChars('a/b')).toEqual('a\\/b'); + }); + + it('should escape multiple special characters in one string', () => { + expect(escapeLuceneSpecialChars('(hello+world)')).toEqual('\\(hello\\+world\\)'); + }); + + it('should escape all special characters when they appear together', () => { + expect(escapeLuceneSpecialChars('+-&|!(){}[]^"~*?:\\/')).toEqual('\\+\\-\\&\\|\\!\\(\\)\\{\\}\\[\\]\\^\\"\\~\\*\\?\\:\\\\\\/'); + }); + +}); describe('SearchOptions', () => { let options: SearchOptions; @@ -40,5 +135,17 @@ describe('SearchOptions', () => { ); }); + it('should escape Lucene special characters in the query', () => { + const specialOptions = new SearchOptions({ query: 'title:foo (bar)+baz' }); + const outcome = specialOptions.toRestUrl(baseUrl); + expect(outcome).toEqual('www.rest.com?query=title%5C%3Afoo%20%5C(bar%5C)%5C%2Bbaz'); + }); + + it('should not escape the query when expert mode is enabled', () => { + const expertOptions = new SearchOptions({ query: 'title:foo', expert: true }); + const outcome = expertOptions.toRestUrl(baseUrl); + expect(outcome).toEqual('www.rest.com?query=title%3Afoo'); + }); + }); }); diff --git a/src/app/core/shared/search/models/search-options.model.ts b/src/app/core/shared/search/models/search-options.model.ts index ab9b308f34f..aa661752589 100644 --- a/src/app/core/shared/search/models/search-options.model.ts +++ b/src/app/core/shared/search/models/search-options.model.ts @@ -8,6 +8,14 @@ import { DSpaceObjectType } from '../../dspace-object-type.model'; import { ViewMode } from '../../view-mode.model'; import { SearchFilter } from './search-filter.model'; +/** + * Escapes all Lucene special characters in a query string so they are treated as literals. + * Special characters: + - & | ! ( ) { } [ ] ^ " ~ * ? : \ / + */ +export function escapeLuceneSpecialChars(query: string): string { + return query.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, '\\$&'); +} + /** * This model class represents all parameters needed to request information about a certain search request */ @@ -16,6 +24,7 @@ export class SearchOptions { view?: ViewMode = ViewMode.ListElement; scope?: string; query?: string; + expert?: boolean; dsoTypes?: DSpaceObjectType[]; filters?: SearchFilter[]; fixedFilter?: string; @@ -23,7 +32,7 @@ export class SearchOptions { constructor( options: { configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], - fixedFilter?: string + fixedFilter?: string, expert?: boolean }, ) { this.configuration = options.configuration; @@ -32,6 +41,7 @@ export class SearchOptions { this.dsoTypes = options.dsoTypes; this.filters = options.filters; this.fixedFilter = options.fixedFilter; + this.expert = options.expert; } /** @@ -48,7 +58,11 @@ export class SearchOptions { args.push(this.encodedFixedFilter); } if (isNotEmpty(this.query)) { - args.push(`query=${encodeURIComponent(this.query)}`); + if (!this.expert) { + args.push(`query=${encodeURIComponent(escapeLuceneSpecialChars(this.query))}`); + } else { + args.push(`query=${encodeURIComponent(this.query)}`); + } } if (isNotEmpty(this.scope)) { args.push(`scope=${encodeURIComponent(this.scope)}`); diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 59156d17778..861e7d1dbfb 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -9,7 +9,12 @@ {{dsoNameService.getName(selectedScope | async) || ('search.form.scope.all' | translate)}} } - + + + + diff --git a/src/app/shared/search-form/search-form.component.scss b/src/app/shared/search-form/search-form.component.scss index cf3a354364f..d63931677ee 100644 --- a/src/app/shared/search-form/search-form.component.scss +++ b/src/app/shared/search-form/search-form.component.scss @@ -7,3 +7,127 @@ .scope-button { max-width: var(--ds-search-form-scope-max-width); } + +// Copied from https://getcssscan.com/css-checkboxes-examples +.checkbox-wrapper-8 .tgl { + display: none; +} + +.checkbox-wrapper-8 .tgl, +.checkbox-wrapper-8 .tgl:after, +.checkbox-wrapper-8 .tgl:before, +.checkbox-wrapper-8 .tgl *, +.checkbox-wrapper-8 .tgl *:after, +.checkbox-wrapper-8 .tgl *:before, +.checkbox-wrapper-8 .tgl+.tgl-btn { + box-sizing: border-box; +} + +.checkbox-wrapper-8 .tgl::-moz-selection, +.checkbox-wrapper-8 .tgl:after::-moz-selection, +.checkbox-wrapper-8 .tgl:before::-moz-selection, +.checkbox-wrapper-8 .tgl *::-moz-selection, +.checkbox-wrapper-8 .tgl *:after::-moz-selection, +.checkbox-wrapper-8 .tgl *:before::-moz-selection, +.checkbox-wrapper-8 .tgl+.tgl-btn::-moz-selection, +.checkbox-wrapper-8 .tgl::selection, +.checkbox-wrapper-8 .tgl:after::selection, +.checkbox-wrapper-8 .tgl:before::selection, +.checkbox-wrapper-8 .tgl *::selection, +.checkbox-wrapper-8 .tgl *:after::selection, +.checkbox-wrapper-8 .tgl *:before::selection, +.checkbox-wrapper-8 .tgl+.tgl-btn::selection { + background: none; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn { + outline: 0; + display: block; + width: 6em; + height: 100%; + position: relative; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:after, +.checkbox-wrapper-8 .tgl+.tgl-btn:before { + position: relative; + display: block; + content: ""; + width: 50%; + height: 100%; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:after { + left: 0; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:before { + display: none; +} + +.checkbox-wrapper-8 .tgl:checked+.tgl-btn:after { + left: 50%; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn { + overflow: hidden; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transition: all 0.2s ease; + font-family: sans-serif; + background: #888; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:after, +.checkbox-wrapper-8 .tgl+.tgl-btn:before { + display: inline-block; + transition: all 0.2s ease; + width: 100%; + text-align: center; + position: absolute; + line-height: 2em; + font-weight: bold; + color: #fff; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.4); +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:after { + left: 100%; + margin-top: 3px; + content: attr(data-tg-on); +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:before { + left: 0; + margin-top: 3px; + content: attr(data-tg-off); +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:active { + background: #888; +} + +.checkbox-wrapper-8 .tgl+.tgl-btn:active:before { + left: -10%; +} + +.checkbox-wrapper-8 .tgl:checked+.tgl-btn { + background: #86d993; +} + +.checkbox-wrapper-8 .tgl:checked+.tgl-btn:before { + left: -100%; +} + +.checkbox-wrapper-8 .tgl:checked+.tgl-btn:after { + left: 0; +} + +.checkbox-wrapper-8 .tgl:checked+.tgl-btn:active:after { + left: 10%; +} \ No newline at end of file diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 87fa6c0bb23..8839beab13e 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -87,7 +87,7 @@ describe('SearchFormComponent', () => { fixture.detectChanges(); tick(); - const queryInput = de.query(By.css('input')).nativeElement; + const queryInput = de.query(By.css('input[name="query"]')).nativeElement; expect(queryInput.value).toBe(testString); })); diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 0bd6f2d8ebc..eb6a4e8b1c9 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -53,6 +53,11 @@ export class SearchFormComponent implements OnChanges { */ @Input() query: string; + /** + * True to pass the search query without esacping of special characters. + */ + @Input() expert: boolean; + /** * True when the search component should show results on the current page */ diff --git a/src/app/shared/search-form/themed-search-form.component.ts b/src/app/shared/search-form/themed-search-form.component.ts index e6db53c3258..41b5dbffef8 100644 --- a/src/app/shared/search-form/themed-search-form.component.ts +++ b/src/app/shared/search-form/themed-search-form.component.ts @@ -19,6 +19,8 @@ export class ThemedSearchFormComponent extends ThemedComponent { + params.expert = true; void this.router.navigate([this.searchService.getSearchLink()], { queryParams: params, }); diff --git a/src/app/shared/search/search-configuration.service.ts b/src/app/shared/search/search-configuration.service.ts index dbd63e1825a..e1eee9dc00a 100644 --- a/src/app/shared/search/search-configuration.service.ts +++ b/src/app/shared/search/search-configuration.service.ts @@ -187,6 +187,15 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable} Emits the current expert string + */ + getCurrentExpert(defaultExpert: boolean) { + return this.routeService.getQueryParameterValue('expert').pipe(map((expert) => { + return expert === 'true' || defaultExpert; + })); + } + /** * @returns {Observable} Emits the current DSpaceObject type as a number */ @@ -360,6 +369,7 @@ export class SearchConfigurationService implements OnDestroy { this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), + this.getExpertPart(defaults.expert), this.getDSOTypePart(), this.getFiltersPart(), this.getFixedFilterPart(), @@ -384,6 +394,7 @@ export class SearchConfigurationService implements OnDestroy { this.getSortPart(paginationId, defaults.sort), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), + this.getExpertPart(defaults.expert), this.getDSOTypePart(), this.getFiltersPart(), this.getFixedFilterPart(), @@ -435,6 +446,15 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable<{expert: boolean}>} Emits the current expert boolean as a partial SearchOptions object + */ + private getExpertPart(defaultExpert: boolean): Observable<{expert: boolean}> { + return this.getCurrentExpert(defaultExpert).pipe(map((expert) => { + return { expert }; + })); + } + /** * @returns {Observable} Emits the current query string as a partial SearchOptions object */ diff --git a/src/app/shared/search/search.component.html b/src/app/shared/search/search.component.html index e0f778e55da..1d8f100844d 100644 --- a/src/app/shared/search/search.component.html +++ b/src/app/shared/search/search.component.html @@ -106,6 +106,7 @@ { configuration: 'default', scope: '', sort: sortOptionsList[0], + expert: false, }); expect(comp.currentConfiguration$).toBeObservable(cold('b', { b: 'default', diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index eaefeeef7ba..9f25c477dfa 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -228,6 +228,11 @@ export class SearchComponent implements OnDestroy, OnInit { */ @Input() query: string; + /** + * True to pass the query as-is without escaping of special characters. + */ + @Input() expert = false; + /** * The fallback scope when no scope is defined in the url, if this is also undefined no scope will be set */ @@ -429,6 +434,9 @@ export class SearchComponent implements OnDestroy, OnInit { if (combinedOptions.query === '') { combinedOptions.query = this.query; } + if (combinedOptions.expert === undefined) { + combinedOptions.expert = this.expert; + } if (isEmpty(combinedOptions.scope)) { combinedOptions.scope = scope; } @@ -536,7 +544,7 @@ export class SearchComponent implements OnDestroy, OnInit { followLinks.push(followLink('supervisionOrders', { isOptional: true }) as any); } - const searchOptionsWithHidden = Object.assign (new PaginatedSearchOptions({}), searchOptions); + const searchOptionsWithHidden = Object.assign(new PaginatedSearchOptions({}), searchOptions); if (isNotEmpty(this.hiddenQuery)) { if (isNotEmpty(searchOptionsWithHidden.query)) { searchOptionsWithHidden.query = searchOptionsWithHidden.query + ' AND ' + this.hiddenQuery; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 1bd83d53467..73f65aee89b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -4944,6 +4944,10 @@ "search.form.search": "Search", + "search.form.expert_on": "Expert", + + "search.form.expert_off": "Simple", + "search.form.search_dspace": "All repository", "search.form.scope.all": "All of DSpace",