From e4fa9481e5682464b3251a4eddfec0fc4632642c Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Wed, 16 Apr 2025 17:49:45 +0200 Subject: [PATCH 1/6] Escaping ':' in search queries, except if advanced=true Following up on the issue https://github.com/DSpace/DSpace/issues/9670 - here is a first proposal. Per default, this sets the 'advanced' flag to false and will escape ':' characters in the search string. Only with 'advanced = true' is the search string passed as-is. TODO: - add tests - make sure this looks good --- src/app/core/shared/search/models/search-options.model.ts | 7 ++++++- src/app/shared/search-form/search-form.component.html | 1 + src/app/shared/search-form/search-form.component.ts | 5 +++++ src/app/shared/search/search.component.ts | 6 ++++++ src/assets/i18n/en.json5 | 2 ++ 5 files changed, 20 insertions(+), 1 deletion(-) 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..549ca300af9 100644 --- a/src/app/core/shared/search/models/search-options.model.ts +++ b/src/app/core/shared/search/models/search-options.model.ts @@ -19,11 +19,12 @@ export class SearchOptions { dsoTypes?: DSpaceObjectType[]; filters?: SearchFilter[]; fixedFilter?: string; + advanced?: boolean; constructor( options: { configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], - fixedFilter?: string + fixedFilter?: string, advanced?: boolean }, ) { this.configuration = options.configuration; @@ -32,6 +33,7 @@ export class SearchOptions { this.dsoTypes = options.dsoTypes; this.filters = options.filters; this.fixedFilter = options.fixedFilter; + this.advanced = options.advanced; } /** @@ -48,6 +50,9 @@ export class SearchOptions { args.push(this.encodedFixedFilter); } if (isNotEmpty(this.query)) { + if (!this.advanced){ + this.query.replace(':', '\:'); + } args.push(`query=${encodeURIComponent(this.query)}`); } if (isNotEmpty(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..97e3aca87f7 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -12,6 +12,7 @@ + diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 0bd6f2d8ebc..82c25dc1b57 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() advanced: boolean; + /** * True when the search component should show results on the current page */ diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index eaefeeef7ba..4c7f67d77b2 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() advanced = false; + /** * The fallback scope when no scope is defined in the url, if this is also undefined no scope will be set */ @@ -428,6 +433,7 @@ export class SearchComponent implements OnDestroy, OnInit { }); if (combinedOptions.query === '') { combinedOptions.query = this.query; + combinedOptions.advanced = this.advanced; } if (isEmpty(combinedOptions.scope)) { combinedOptions.scope = scope; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 1bd83d53467..95653fcdc72 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -4944,6 +4944,8 @@ "search.form.search": "Search", + "search.form.search": "Advanced", + "search.form.search_dspace": "All repository", "search.form.scope.all": "All of DSpace", From b67c1b56215bf66a95764cba8fe4a6acd92a8516 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Thu, 17 Apr 2025 09:56:13 +0200 Subject: [PATCH 2/6] More propagation of 'advanced', and correct replacement of ':' --- src/app/core/shared/search/models/search-options.model.ts | 7 ++++--- src/app/shared/search-form/search-form.component.html | 2 +- src/app/shared/search-form/themed-search-form.component.ts | 3 +++ .../search/advanced-search/advanced-search.component.ts | 1 + src/app/shared/search/search.component.html | 1 + 5 files changed, 10 insertions(+), 4 deletions(-) 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 549ca300af9..2d1b099bca2 100644 --- a/src/app/core/shared/search/models/search-options.model.ts +++ b/src/app/core/shared/search/models/search-options.model.ts @@ -50,10 +50,11 @@ export class SearchOptions { args.push(this.encodedFixedFilter); } if (isNotEmpty(this.query)) { - if (!this.advanced){ - this.query.replace(':', '\:'); + if (!this.advanced) { + args.push(`query=${encodeURIComponent(this.query.replace(':', '\\:'))}`); + } else { + args.push(`query=${encodeURIComponent(this.query)}`); } - 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 97e3aca87f7..eea6187f4b8 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -12,7 +12,7 @@ - + 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..6f9c45c3d15 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.advanced = true; void this.router.navigate([this.searchService.getSearchLink()], { queryParams: params, }); diff --git a/src/app/shared/search/search.component.html b/src/app/shared/search/search.component.html index e0f778e55da..a648b565f2e 100644 --- a/src/app/shared/search/search.component.html +++ b/src/app/shared/search/search.component.html @@ -106,6 +106,7 @@ Date: Thu, 17 Apr 2025 21:54:32 +0200 Subject: [PATCH 3/6] Adding nicer checkbox, and verifying extended search actually works. --- .gitignore | 1 + karma.conf.js | 2 +- .../models/paginated-search-options.model.ts | 2 +- .../search/models/search-options.model.ts | 2 +- .../search-form/search-form.component.html | 8 +- .../search-form/search-form.component.scss | 124 ++++++++++++++++++ .../search-form/search-form.component.spec.ts | 2 +- .../search/search-configuration.service.ts | 21 +++ .../shared/search/search.component.spec.ts | 1 + src/app/shared/search/search.component.ts | 6 +- src/assets/i18n/en.json5 | 4 +- 11 files changed, 165 insertions(+), 8 deletions(-) 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/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..8d74c8434fe 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, advanced?: boolean}) { super(options); this.pagination = options.pagination; this.sort = options.sort; 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 2d1b099bca2..8b8f078d537 100644 --- a/src/app/core/shared/search/models/search-options.model.ts +++ b/src/app/core/shared/search/models/search-options.model.ts @@ -16,10 +16,10 @@ export class SearchOptions { view?: ViewMode = ViewMode.ListElement; scope?: string; query?: string; + advanced?: boolean; dsoTypes?: DSpaceObjectType[]; filters?: SearchFilter[]; fixedFilter?: string; - advanced?: boolean; constructor( options: { diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index eea6187f4b8..3265305c2a0 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -9,10 +9,14 @@ {{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/search-configuration.service.ts b/src/app/shared/search/search-configuration.service.ts index dbd63e1825a..f18dc47a62e 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 advanced string + */ + getCurrentAdvanced(defaultAdvanced: boolean) { + return this.routeService.getQueryParameterValue('advanced').pipe(map((advanced) => { + return advanced === 'true' || defaultAdvanced; + })); + } + /** * @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.getAdvancedPart(defaults.advanced), 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.getAdvancedPart(defaults.advanced), this.getDSOTypePart(), this.getFiltersPart(), this.getFixedFilterPart(), @@ -435,6 +446,16 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable<{advanced: boolean}>} Emits the current advanced boolean as a partial SearchOptions object + */ + private getAdvancedPart(defaultAdvanced: boolean): Observable<{advanced: boolean}> { + return this.getCurrentAdvanced(defaultAdvanced).pipe(map((advanced) => { + console.log("getAdvancedPart", advanced) + return { advanced }; + })); + } + /** * @returns {Observable} Emits the current query string as a partial SearchOptions object */ diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index 7d5adbb3a2f..7a13eafc334 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -300,6 +300,7 @@ describe('SearchComponent', () => { configuration: 'default', scope: '', sort: sortOptionsList[0], + advanced: 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 4c7f67d77b2..e003ef2d30d 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -426,6 +426,7 @@ export class SearchComponent implements OnDestroy, OnInit { debounceTime(100), ).subscribe(([configuration, searchSortOptions, searchOptions, sortOption, scope]: [string, SortOptions[], PaginatedSearchOptions, SortOptions, string]) => { // Build the PaginatedSearchOptions object + console.log("searchOptions", searchOptions); const combinedOptions = Object.assign({}, searchOptions, { configuration: searchOptions.configuration || configuration, @@ -433,8 +434,11 @@ export class SearchComponent implements OnDestroy, OnInit { }); if (combinedOptions.query === '') { combinedOptions.query = this.query; + } + if (combinedOptions.advanced === undefined) { combinedOptions.advanced = this.advanced; } + console.log(this.advanced, combinedOptions.advanced); if (isEmpty(combinedOptions.scope)) { combinedOptions.scope = scope; } @@ -542,7 +546,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 95653fcdc72..73f65aee89b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -4944,7 +4944,9 @@ "search.form.search": "Search", - "search.form.search": "Advanced", + "search.form.expert_on": "Expert", + + "search.form.expert_off": "Simple", "search.form.search_dspace": "All repository", From 0b642b9d5cd14900873167937915cc5faaffdb50 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Thu, 17 Apr 2025 22:01:03 +0200 Subject: [PATCH 4/6] Replacing 'advanced' with 'expert' --- .../models/paginated-search-options.model.ts | 2 +- .../search/models/search-options.model.ts | 8 +++---- .../search-form/search-form.component.html | 2 +- .../search-form/search-form.component.ts | 2 +- .../themed-search-form.component.ts | 4 ++-- .../advanced-search.component.ts | 2 +- .../search/search-configuration.service.ts | 21 +++++++++---------- src/app/shared/search/search.component.html | 2 +- .../shared/search/search.component.spec.ts | 2 +- src/app/shared/search/search.component.ts | 8 +++---- 10 files changed, 25 insertions(+), 28 deletions(-) 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 8d74c8434fe..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, advanced?: boolean}) { + 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.ts b/src/app/core/shared/search/models/search-options.model.ts index 8b8f078d537..63c26fb0e1d 100644 --- a/src/app/core/shared/search/models/search-options.model.ts +++ b/src/app/core/shared/search/models/search-options.model.ts @@ -16,7 +16,7 @@ export class SearchOptions { view?: ViewMode = ViewMode.ListElement; scope?: string; query?: string; - advanced?: boolean; + expert?: boolean; dsoTypes?: DSpaceObjectType[]; filters?: SearchFilter[]; fixedFilter?: string; @@ -24,7 +24,7 @@ export class SearchOptions { constructor( options: { configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], - fixedFilter?: string, advanced?: boolean + fixedFilter?: string, expert?: boolean }, ) { this.configuration = options.configuration; @@ -33,7 +33,7 @@ export class SearchOptions { this.dsoTypes = options.dsoTypes; this.filters = options.filters; this.fixedFilter = options.fixedFilter; - this.advanced = options.advanced; + this.expert = options.expert; } /** @@ -50,7 +50,7 @@ export class SearchOptions { args.push(this.encodedFixedFilter); } if (isNotEmpty(this.query)) { - if (!this.advanced) { + if (!this.expert) { args.push(`query=${encodeURIComponent(this.query.replace(':', '\\:'))}`); } else { args.push(`query=${encodeURIComponent(this.query)}`); diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 3265305c2a0..861e7d1dbfb 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -10,7 +10,7 @@ }
- +
diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 82c25dc1b57..eb6a4e8b1c9 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -56,7 +56,7 @@ export class SearchFormComponent implements OnChanges { /** * True to pass the search query without esacping of special characters. */ - @Input() advanced: boolean; + @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 6f9c45c3d15..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,7 +19,7 @@ export class ThemedSearchFormComponent extends ThemedComponent { - params.advanced = true; + 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 f18dc47a62e..e1eee9dc00a 100644 --- a/src/app/shared/search/search-configuration.service.ts +++ b/src/app/shared/search/search-configuration.service.ts @@ -188,11 +188,11 @@ export class SearchConfigurationService implements OnDestroy { } /** - * @returns {Observable} Emits the current advanced string + * @returns {Observable} Emits the current expert string */ - getCurrentAdvanced(defaultAdvanced: boolean) { - return this.routeService.getQueryParameterValue('advanced').pipe(map((advanced) => { - return advanced === 'true' || defaultAdvanced; + getCurrentExpert(defaultExpert: boolean) { + return this.routeService.getQueryParameterValue('expert').pipe(map((expert) => { + return expert === 'true' || defaultExpert; })); } @@ -369,7 +369,7 @@ export class SearchConfigurationService implements OnDestroy { this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), - this.getAdvancedPart(defaults.advanced), + this.getExpertPart(defaults.expert), this.getDSOTypePart(), this.getFiltersPart(), this.getFixedFilterPart(), @@ -394,7 +394,7 @@ export class SearchConfigurationService implements OnDestroy { this.getSortPart(paginationId, defaults.sort), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), - this.getAdvancedPart(defaults.advanced), + this.getExpertPart(defaults.expert), this.getDSOTypePart(), this.getFiltersPart(), this.getFixedFilterPart(), @@ -447,12 +447,11 @@ export class SearchConfigurationService implements OnDestroy { } /** - * @returns {Observable<{advanced: boolean}>} Emits the current advanced boolean as a partial SearchOptions object + * @returns {Observable<{expert: boolean}>} Emits the current expert boolean as a partial SearchOptions object */ - private getAdvancedPart(defaultAdvanced: boolean): Observable<{advanced: boolean}> { - return this.getCurrentAdvanced(defaultAdvanced).pipe(map((advanced) => { - console.log("getAdvancedPart", advanced) - return { advanced }; + private getExpertPart(defaultExpert: boolean): Observable<{expert: boolean}> { + return this.getCurrentExpert(defaultExpert).pipe(map((expert) => { + return { expert }; })); } diff --git a/src/app/shared/search/search.component.html b/src/app/shared/search/search.component.html index a648b565f2e..1d8f100844d 100644 --- a/src/app/shared/search/search.component.html +++ b/src/app/shared/search/search.component.html @@ -106,7 +106,7 @@ { configuration: 'default', scope: '', sort: sortOptionsList[0], - advanced: false, + 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 e003ef2d30d..9f25c477dfa 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -231,7 +231,7 @@ export class SearchComponent implements OnDestroy, OnInit { /** * True to pass the query as-is without escaping of special characters. */ - @Input() advanced = false; + @Input() expert = false; /** * The fallback scope when no scope is defined in the url, if this is also undefined no scope will be set @@ -426,7 +426,6 @@ export class SearchComponent implements OnDestroy, OnInit { debounceTime(100), ).subscribe(([configuration, searchSortOptions, searchOptions, sortOption, scope]: [string, SortOptions[], PaginatedSearchOptions, SortOptions, string]) => { // Build the PaginatedSearchOptions object - console.log("searchOptions", searchOptions); const combinedOptions = Object.assign({}, searchOptions, { configuration: searchOptions.configuration || configuration, @@ -435,10 +434,9 @@ export class SearchComponent implements OnDestroy, OnInit { if (combinedOptions.query === '') { combinedOptions.query = this.query; } - if (combinedOptions.advanced === undefined) { - combinedOptions.advanced = this.advanced; + if (combinedOptions.expert === undefined) { + combinedOptions.expert = this.expert; } - console.log(this.advanced, combinedOptions.advanced); if (isEmpty(combinedOptions.scope)) { combinedOptions.scope = scope; } From d8d704be0646237f658d59f15f9012f30fc23351 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Fri, 18 Apr 2025 18:48:55 +0200 Subject: [PATCH 5/6] Adding e2e tests --- cypress/e2e/search-navbar.cy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From d170a8c7e7f36a2695dc791dd9eabf94dbe983c4 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Fri, 13 Mar 2026 11:08:54 +0100 Subject: [PATCH 6/6] Add all Lucene special characters for escaping --- .../models/search-options.model.spec.ts | 109 +++++++++++++++++- .../search/models/search-options.model.ts | 10 +- 2 files changed, 117 insertions(+), 2 deletions(-) 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 63c26fb0e1d..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 */ @@ -51,7 +59,7 @@ export class SearchOptions { } if (isNotEmpty(this.query)) { if (!this.expert) { - args.push(`query=${encodeURIComponent(this.query.replace(':', '\\:'))}`); + args.push(`query=${encodeURIComponent(escapeLuceneSpecialChars(this.query))}`); } else { args.push(`query=${encodeURIComponent(this.query)}`); }