From 3562080e7ee6e06e99c9ea210a1b79e135051c2e Mon Sep 17 00:00:00 2001 From: Simon Walenkamp Hansen Date: Wed, 4 Dec 2024 14:09:04 +0100 Subject: [PATCH] Feat: add search highlighting --- README.md | 2 +- .../search/contentHighlightingBuilder.ts | 50 +++++++++++ .../builders/search/contentSearchBuilder.ts | 10 +++ .../search/productHighlightingBuilder.ts | 50 +++++++++++ .../builders/search/productSearchBuilder.ts | 12 ++- packages/client/src/models/data-contracts.ts | 87 +++++++++++++++++++ .../contentSearch.integration.test.ts | 17 +++- .../productSearch.integration.test.ts | 15 ++++ .../search/contentSearchBuilder.unit.test.ts | 41 +++++++++ .../search/productSearchBuilder.unit.test.ts | 28 ++++++ 10 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 packages/client/src/builders/search/contentHighlightingBuilder.ts create mode 100644 packages/client/src/builders/search/productHighlightingBuilder.ts create mode 100644 packages/client/tests/unit-tests/builders/search/contentSearchBuilder.unit.test.ts diff --git a/README.md b/README.md index 12fff10..6f6b146 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ For more information about how to use the SDK via CDN - go to our [docs site](ht ## Running integration tests -You can read about running the integration tests [here](/lib/dev.guide.md#testing). +You can read about running the integration tests [here](/packages/client/dev.guide.md#testing). ## Contributing diff --git a/packages/client/src/builders/search/contentHighlightingBuilder.ts b/packages/client/src/builders/search/contentHighlightingBuilder.ts new file mode 100644 index 0000000..782893b --- /dev/null +++ b/packages/client/src/builders/search/contentHighlightingBuilder.ts @@ -0,0 +1,50 @@ +import { ContentHighlightProps, ContentSearchSettingsHighlightSettings, HighlightSettings2ContentContentHighlightPropsHighlightSettings2Limits, HighlightSettings2ContentContentHighlightPropsHighlightSettings2ResponseShape } from "src/models/data-contracts"; + +export class ContentHighlightingBuilder { + private enabled: boolean = true; + private highlightable: ContentHighlightProps = { + $type: 'Relewise.Client.Requests.Shared.Highlighting.ContentHighlightProps, Relewise.Client', + displayName: false + }; + private limit: HighlightSettings2ContentContentHighlightPropsHighlightSettings2Limits = {}; + private shape: HighlightSettings2ContentContentHighlightPropsHighlightSettings2ResponseShape = { + includeOffsets: false + }; + + public enable(enabled: boolean): this { + this.enabled = enabled; + + return this; + } + + public setHighlightable(highlightable: { displayName?: boolean, dataKeys?: string[] | null }): this { + this.highlightable.displayName = highlightable.displayName ?? false; + this.highlightable.dataKeys = highlightable.dataKeys; + + return this; + } + + public setLimit(limit: { maxEntryLimit?: number | null; maxSnippetsPerEntry?: number | null; maxSnippetsPerField?: number | null; }): this { + this.limit.maxEntryLimit = limit.maxEntryLimit; + this.limit.maxSnippetsPerEntry = limit.maxSnippetsPerEntry; + this.limit.maxSnippetsPerField = limit.maxSnippetsPerField; + + return this; + } + + public setShape(shape: { includeOffsets: boolean }): this { + this.shape.includeOffsets = shape.includeOffsets; + + return this; + } + + public build(): ContentSearchSettingsHighlightSettings { + return { + $type: 'Relewise.Client.Requests.Search.Settings.ContentSearchSettings+HighlightSettings, Relewise.Client', + enabled: this.enabled, + highlightable: this.highlightable, + limit: this.limit, + shape: this.shape + } as ContentSearchSettingsHighlightSettings; + } +} \ No newline at end of file diff --git a/packages/client/src/builders/search/contentSearchBuilder.ts b/packages/client/src/builders/search/contentSearchBuilder.ts index 76921ef..da29a77 100644 --- a/packages/client/src/builders/search/contentSearchBuilder.ts +++ b/packages/client/src/builders/search/contentSearchBuilder.ts @@ -1,6 +1,7 @@ import { ContentSearchRequest, ContentSearchSettings, RecommendationSettings, SelectedContentPropertiesSettings } from '../../models/data-contracts'; import { PaginationBuilder } from '../paginationBuilder'; import { Settings } from '../settings'; +import { ContentHighlightingBuilder } from './contentHighlightingBuilder'; import { ContentSortingBuilder } from './contentSortingBuilder'; import { FacetBuilder } from './facetBuilder'; import { SearchBuilder } from './searchBuilder'; @@ -11,6 +12,7 @@ export class ContentSearchBuilder extends SearchRequestBuilder implements Search private paginationBuilder: PaginationBuilder = new PaginationBuilder(); private sortingBuilder: ContentSortingBuilder = new ContentSortingBuilder(); private term: string | null | undefined; + private highlightingBuilder = new ContentHighlightingBuilder(); private searchSettings: ContentSearchSettings = { $type: 'Relewise.Client.Requests.Search.Settings.ContentSearchSettings, Relewise.Client', @@ -57,6 +59,14 @@ export class ContentSearchBuilder extends SearchRequestBuilder implements Search return this; } + public highlighting(highlightingBuilder: (highlightingBuilder: ContentHighlightingBuilder) => void): this { + highlightingBuilder(this.highlightingBuilder); + + this.searchSettings.highlight = this.highlightingBuilder.build(); + + return this; + } + public build(): ContentSearchRequest { const { take, skip } = this.paginationBuilder.build(); return { diff --git a/packages/client/src/builders/search/productHighlightingBuilder.ts b/packages/client/src/builders/search/productHighlightingBuilder.ts new file mode 100644 index 0000000..7a3b286 --- /dev/null +++ b/packages/client/src/builders/search/productHighlightingBuilder.ts @@ -0,0 +1,50 @@ +import { HighlightSettings2ProductProductHighlightPropsHighlightSettings2Limits, HighlightSettings2ProductProductHighlightPropsHighlightSettings2ResponseShape, ProductHighlightProps, ProductSearchSettingsHighlightSettings } from "src/models/data-contracts"; + +export class ProductHighlightingBuilder { + private enabled: boolean = true; + private highlightable: ProductHighlightProps = { + $type: 'Relewise.Client.Requests.Shared.Highlighting.ProductHighlightProps, Relewise.Client', + displayName: false + }; + private limit: HighlightSettings2ProductProductHighlightPropsHighlightSettings2Limits = {}; + private shape: HighlightSettings2ProductProductHighlightPropsHighlightSettings2ResponseShape = { + includeOffsets: false + }; + + public enable(enabled: boolean): this { + this.enabled = enabled; + + return this; + } + + public setHighlightable(highlightable: { displayName?: boolean, dataKeys?: string[] | null }): this { + this.highlightable.displayName = highlightable.displayName ?? false; + this.highlightable.dataKeys = highlightable.dataKeys; + + return this; + } + + public setLimit(limit: { maxEntryLimit?: number | null; maxSnippetsPerEntry?: number | null; maxSnippetsPerField?: number | null; }): this { + this.limit.maxEntryLimit = limit.maxEntryLimit; + this.limit.maxSnippetsPerEntry = limit.maxSnippetsPerEntry; + this.limit.maxSnippetsPerField = limit.maxSnippetsPerField; + + return this; + } + + public setShape(shape: { includeOffsets: boolean }): this { + this.shape.includeOffsets = shape.includeOffsets; + + return this; + } + + public build(): ProductSearchSettingsHighlightSettings { + return { + $type: 'Relewise.Client.Requests.Search.Settings.ProductSearchSettings+HighlightSettings, Relewise.Client', + enabled: this.enabled, + highlightable: this.highlightable, + limit: this.limit, + shape: this.shape + } as ProductSearchSettingsHighlightSettings; + } +} \ No newline at end of file diff --git a/packages/client/src/builders/search/productSearchBuilder.ts b/packages/client/src/builders/search/productSearchBuilder.ts index b934b50..8af5703 100644 --- a/packages/client/src/builders/search/productSearchBuilder.ts +++ b/packages/client/src/builders/search/productSearchBuilder.ts @@ -2,6 +2,7 @@ import { ProductSearchRequest, ProductSearchSettings, RecommendationSettings, Re import { PaginationBuilder } from '../paginationBuilder'; import { Settings } from '../settings'; import { FacetBuilder } from './facetBuilder'; +import { ProductHighlightingBuilder } from './productHighlightingBuilder'; import { ProductSortingBuilder } from './productSortingBuilder'; import { SearchBuilder } from './searchBuilder'; import { SearchConstraintBuilder } from './searchConstraintBuilder'; @@ -14,6 +15,7 @@ export class ProductSearchBuilder extends SearchRequestBuilder implements Search private sortingBuilder: ProductSortingBuilder = new ProductSortingBuilder(); private searchConstraintBuilder: SearchConstraintBuilder = new SearchConstraintBuilder(); private term: string | null | undefined; + private highlightingBuilder = new ProductHighlightingBuilder(); private searchSettings: ProductSearchSettings = { $type: 'Relewise.Client.Requests.Search.Settings.ProductSearchSettings, Relewise.Client', @@ -114,6 +116,14 @@ export class ProductSearchBuilder extends SearchRequestBuilder implements Search return this; } + public highlighting(highlightingBuilder: (highlightingBuilder: ProductHighlightingBuilder) => void): this { + highlightingBuilder(this.highlightingBuilder); + + this.searchSettings.highlight = this.highlightingBuilder.build(); + + return this; + } + public build(): ProductSearchRequest { const { take, skip } = this.paginationBuilder.build(); return { @@ -121,9 +131,7 @@ export class ProductSearchBuilder extends SearchRequestBuilder implements Search ...this.baseBuild(), take, skip, - term: this.term, - facets: this.facetBuilder.build(), settings: this.searchSettings, sorting: this.sortingBuilder.build(), diff --git a/packages/client/src/models/data-contracts.ts b/packages/client/src/models/data-contracts.ts index f1938f2..dd369ef 100644 --- a/packages/client/src/models/data-contracts.ts +++ b/packages/client/src/models/data-contracts.ts @@ -1130,6 +1130,14 @@ export type ContentCategoryView = Trackable & { channel?: Channel | null; }; +export interface ContentContentHighlightPropsHighlightSettings { + $type: string; + enabled: boolean; + limit: HighlightSettings2ContentContentHighlightPropsHighlightSettings2Limits; + highlightable: ContentHighlightProps; + shape: HighlightSettings2ContentContentHighlightPropsHighlightSettings2ResponseShape; +} + export type ContentDataBooleanValueFacet = BooleanContentDataValueFacet; export type ContentDataBooleanValueFacetResult = BooleanContentDataValueFacetResult; @@ -1265,6 +1273,14 @@ export interface ContentFacetResult { export type ContentHasCategoriesFilter = Filter; +export interface ContentHighlightProperties { + $type: string; + displayName: boolean; + dataKeys?: string[] | null; +} + +export type ContentHighlightProps = ContentHighlightProperties; + export type ContentIdFilter = Filter & { contentIds: string[]; }; @@ -1359,6 +1375,7 @@ export interface ContentResult { categoryPaths?: CategoryPathResult[] | null; viewedByUser?: ViewedByUserInfo | null; custom?: Record; + highlight?: HighlightResult | null; } export interface ContentResultDetails { @@ -1397,8 +1414,11 @@ export type ContentSearchResponse = PaginatedSearchResponse & { export type ContentSearchSettings = SearchSettings & { selectedContentProperties?: SelectedContentPropertiesSettings | null; recommendations: RecommendationSettings; + highlight?: ContentSearchSettingsHighlightSettings | null; }; +export type ContentSearchSettingsHighlightSettings = ContentContentHighlightPropsHighlightSettings; + export interface ContentSortBySpecification { value?: ContentAttributeSorting | ContentDataSorting | ContentPopularitySorting | ContentRelevanceSorting | null; } @@ -2323,6 +2343,41 @@ export type HasRecentlyReceivedTriggerCondition = UserCondition & { export type HasValueCondition = ValueCondition; +export interface HighlightResult { + offsets?: HighlightResultOffset | null; +} + +export interface HighlightResultOffset { + displayName: Int32Range[]; + data: StringRange1ArrayKeyValuePair[]; +} + +export interface HighlightSettings2ContentContentHighlightPropsHighlightSettings2Limits { + /** @format int32 */ + maxEntryLimit?: number | null; + /** @format int32 */ + maxSnippetsPerEntry?: number | null; + /** @format int32 */ + maxSnippetsPerField?: number | null; +} + +export interface HighlightSettings2ContentContentHighlightPropsHighlightSettings2ResponseShape { + includeOffsets: boolean; +} + +export interface HighlightSettings2ProductProductHighlightPropsHighlightSettings2Limits { + /** @format int32 */ + maxEntryLimit?: number | null; + /** @format int32 */ + maxSnippetsPerEntry?: number | null; + /** @format int32 */ + maxSnippetsPerField?: number | null; +} + +export interface HighlightSettings2ProductProductHighlightPropsHighlightSettings2ResponseShape { + includeOffsets: boolean; +} + export type HtmlParser = Parser; export type IChange = object; @@ -2398,6 +2453,13 @@ export interface Int32ProductDataValueFacetResult { field: "Category" | "Assortment" | "ListPrice" | "SalesPrice" | "Brand" | "Data" | "VariantSpecification" | "User"; } +export interface Int32Range { + /** @format int32 */ + lowerBoundInclusive: number; + /** @format int32 */ + upperBoundInclusive: number; +} + export interface KeyMultiplier { key?: string | null; /** @format double */ @@ -3652,6 +3714,14 @@ export type ProductHasVariantsFilter = Filter & { numberOfVariants: Int32NullableRange; }; +export interface ProductHighlightProperties { + $type: string; + displayName: boolean; + dataKeys?: string[] | null; +} + +export type ProductHighlightProps = ProductHighlightProperties; + export type ProductIdFilter = Filter & { productIds: string[]; }; @@ -3820,6 +3890,14 @@ export interface ProductPerformanceResultViewsMetrics { export type ProductPopularitySorting = ProductSorting; +export interface ProductProductHighlightPropsHighlightSettings { + $type: string; + enabled: boolean; + limit: HighlightSettings2ProductProductHighlightPropsHighlightSettings2Limits; + highlightable: ProductHighlightProps; + shape: HighlightSettings2ProductProductHighlightPropsHighlightSettings2ResponseShape; +} + export type ProductPromotion = Promotion & { filters?: FilterCollection | null; }; @@ -4070,6 +4148,7 @@ export interface ProductResult { purchasedByUserCompany?: PurchasedByUserCompanyInfo | null; viewedByUserCompany?: ViewedByUserCompanyInfo | null; filteredVariants?: VariantResult[] | null; + highlight?: HighlightResult | null; } export interface ProductResultDetails { @@ -4149,8 +4228,11 @@ export type ProductSearchSettings = SearchSettings & { selectedBrandProperties?: SelectedBrandPropertiesSettings | null; variantSettings?: VariantSearchSettings | null; resultConstraint?: ResultMustHaveVariantConstraint | null; + highlight?: ProductSearchSettingsHighlightSettings | null; }; +export type ProductSearchSettingsHighlightSettings = ProductProductHighlightPropsHighlightSettings; + export interface ProductSortBySpecification { value?: | ProductAttributeSorting @@ -5206,6 +5288,11 @@ export interface StringProductDataValueFacetResult { field: "Category" | "Assortment" | "ListPrice" | "SalesPrice" | "Brand" | "Data" | "VariantSpecification" | "User"; } +export interface StringRange1ArrayKeyValuePair { + key: string; + value: Int32Range[]; +} + export interface StringStringKeyValuePair { key: string; value: string; diff --git a/packages/client/tests/integration-tests/contentSearch.integration.test.ts b/packages/client/tests/integration-tests/contentSearch.integration.test.ts index b9f08a9..d113f74 100644 --- a/packages/client/tests/integration-tests/contentSearch.integration.test.ts +++ b/packages/client/tests/integration-tests/contentSearch.integration.test.ts @@ -35,4 +35,19 @@ test('Facet result', async() => { } expect(result?.hits).toBeGreaterThan(0); -}); \ No newline at end of file +}); + +test('Highlighting', async() => { + const request: ContentSearchRequest = baseContentBuilder() + .setTerm('highlighted') + .highlighting(h => { + h.setHighlightable({ dataKeys: ['Description'] }) + // You have to specify to include the offset. + // Currently offset is the only way to get a result, so if not set, you won't get a result. + h.setShape({ includeOffsets: true }) + }).build(); + + const result = await searcher.searchContents(request); + + expect(result?.results![0].highlight?.offsets?.data[0].value.length).toBeGreaterThan(0); +}) \ No newline at end of file diff --git a/packages/client/tests/integration-tests/productSearch.integration.test.ts b/packages/client/tests/integration-tests/productSearch.integration.test.ts index 74c0f81..c751a61 100644 --- a/packages/client/tests/integration-tests/productSearch.integration.test.ts +++ b/packages/client/tests/integration-tests/productSearch.integration.test.ts @@ -94,3 +94,18 @@ test('ProductSearch with search constraint', async() => { expect(result?.hits).toBeGreaterThan(0); }); + +test('Highlighting', async() => { + const request: ProductSearchRequest = baseProductBuilder() + .setTerm('highlighted') + .highlighting(h => { + h.setHighlightable({ dataKeys: ['Description'] }) + // You have to specify to include the offset. + // Currently offset is the only way to get a result, so if not set, you won't get a result. + h.setShape({ includeOffsets: true }) + }).build(); + + const result = await searcher.searchProducts(request); + + expect(result?.results![0].highlight?.offsets?.data[0].value.length).toBeGreaterThan(0); +}) diff --git a/packages/client/tests/unit-tests/builders/search/contentSearchBuilder.unit.test.ts b/packages/client/tests/unit-tests/builders/search/contentSearchBuilder.unit.test.ts new file mode 100644 index 0000000..266b416 --- /dev/null +++ b/packages/client/tests/unit-tests/builders/search/contentSearchBuilder.unit.test.ts @@ -0,0 +1,41 @@ +import { UserFactory } from '../../../../src/factory'; +import { ContentSearchBuilder } from '../../../../src/builders/search'; +import { test, expect } from '@jest/globals' +import { ContentSearchRequest } from '../../../../src/models/data-contracts'; + +function baseBuilder() { + return new ContentSearchBuilder({ + language: 'da-DK', + currency: 'DKK', + displayedAtLocation: 'search page', + user: UserFactory.anonymous(), + }); +}; + +test('searchHightlighting', () => { + const subject: ContentSearchRequest = baseBuilder() + .highlighting(h => { + h.enable(false); + h.setHighlightable({ + displayName: true, + dataKeys: ['datakey-1', 'datakey-2'] + }); + h.setLimit({ + maxEntryLimit: 1, + maxSnippetsPerEntry: 2, + maxSnippetsPerField: 3 + }); + h.setShape({ + includeOffsets: true + }); + }).build(); + + expect(subject.settings?.highlight?.enabled).toBe(false); + expect(subject.settings?.highlight?.highlightable.displayName).toBe(true); + expect(subject.settings?.highlight?.highlightable.dataKeys![0]).toBe('datakey-1'); + expect(subject.settings?.highlight?.highlightable.dataKeys![1]).toBe('datakey-2'); + expect(subject.settings?.highlight?.limit.maxEntryLimit).toBe(1); + expect(subject.settings?.highlight?.limit.maxSnippetsPerEntry).toBe(2); + expect(subject.settings?.highlight?.limit.maxSnippetsPerField).toBe(3); + expect(subject.settings?.highlight?.shape.includeOffsets).toBe(true); +}); \ No newline at end of file diff --git a/packages/client/tests/unit-tests/builders/search/productSearchBuilder.unit.test.ts b/packages/client/tests/unit-tests/builders/search/productSearchBuilder.unit.test.ts index dc8439d..939f8a6 100644 --- a/packages/client/tests/unit-tests/builders/search/productSearchBuilder.unit.test.ts +++ b/packages/client/tests/unit-tests/builders/search/productSearchBuilder.unit.test.ts @@ -104,4 +104,32 @@ test('resultMustHaveVariantConstraint', () => { .build(); expect(subject.settings?.resultConstraint?.exceptWhenProductHasNoVariants).toBe(true); +}); + +test('searchHightlighting', () => { + const subject: ProductSearchRequest = baseBuilder() + .highlighting(h => { + h.enable(false); + h.setHighlightable({ + displayName: true, + dataKeys: ['datakey-1', 'datakey-2'] + }); + h.setLimit({ + maxEntryLimit: 1, + maxSnippetsPerEntry: 2, + maxSnippetsPerField: 3 + }); + h.setShape({ + includeOffsets: true + }); + }).build(); + + expect(subject.settings?.highlight?.enabled).toBe(false); + expect(subject.settings?.highlight?.highlightable.displayName).toBe(true); + expect(subject.settings?.highlight?.highlightable.dataKeys![0]).toBe('datakey-1'); + expect(subject.settings?.highlight?.highlightable.dataKeys![1]).toBe('datakey-2'); + expect(subject.settings?.highlight?.limit.maxEntryLimit).toBe(1); + expect(subject.settings?.highlight?.limit.maxSnippetsPerEntry).toBe(2); + expect(subject.settings?.highlight?.limit.maxSnippetsPerField).toBe(3); + expect(subject.settings?.highlight?.shape.includeOffsets).toBe(true); }); \ No newline at end of file