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",