/*! * SAPUI5 * Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. */ import { Sina } from "../../sina/Sina"; import { Query } from "../../sina/Query"; import { AttributeMetadata } from "../../sina/AttributeMetadata"; import { AttributeGroupMetadata } from "../../sina/AttributeGroupMetadata"; import { ResultSet } from "../../sina/ResultSet"; import { SearchResultSetItemAttribute } from "../../sina/SearchResultSetItemAttribute"; import { SearchQuery } from "../../sina/SearchQuery"; import { SearchResultSet } from "../../sina/SearchResultSet"; import { SearchResultSetItem } from "../../sina/SearchResultSetItem"; import { DataSourceResultSet } from "../../sina/DataSourceResultSet"; import { ChartQuery } from "../../sina/ChartQuery"; import { ChartResultSet } from "../../sina/ChartResultSet"; import { ChartResultSetItem } from "../../sina/ChartResultSetItem"; import { FacetType } from "../../sina/FacetType"; import { SuggestionQuery } from "../../sina/SuggestionQuery"; import { SuggestionType } from "../../sina/SuggestionType"; import { SuggestionResultSet } from "../../sina/SuggestionResultSet"; import { Suggestion } from "../../sina/Suggestion"; import { ComplexCondition } from "../../sina/ComplexCondition"; import { ComparisonOperator } from "../../sina/ComparisonOperator"; import { SimpleCondition } from "../../sina/SimpleCondition"; import { LogicalOperator } from "../../sina/LogicalOperator"; import { NavigationTarget } from "../../sina/NavigationTarget"; import { RecordService, RecordResponse, Record } from "./RecordService"; import { DataSourceService, DataSourceResponse } from "./DataSourceService"; import { Facet, FacetService } from "./FacetService"; import { HierarchyService } from "./HierarchyService"; import { getMatchedStringValues, formatHighlightedValue, isStarString } from "./Util"; import { HierarchyNodePath } from "../../sina/HierarchyNodePath"; export class SearchEngine { dataSourceService: DataSourceService; recordService: RecordService; facetService: FacetService; hierarchyService: HierarchyService; historySearchTerms: string[] = []; sina: Sina; readonly dataSourceIds: string[]; constructor(sina: Sina, dataSourceIds: string[]) { this.sina = sina; this.dataSourceIds = dataSourceIds; // typically in constructor sina is undefined, so initialize it in initAsync } async initAsync(sina?: Sina): Promise { this.sina = sina ?? this.sina; this.dataSourceService = new DataSourceService(this.sina, this.dataSourceIds); this.recordService = new RecordService(this.sina, this.dataSourceIds); this.facetService = new FacetService(this.sina, this.recordService); this.hierarchyService = new HierarchyService(this.sina, this.recordService); await this.loadData(); } async loadData(): Promise { await this.dataSourceService.loadDataSources(); await this.recordService.loadRecords(); await this.hierarchyService.parseHierarchyRecords(); } async search(query: SearchQuery): Promise { await this.recordService.loadRecords(); // in memory search term history if ( !isStarString(query.filter.searchTerm) && !this.historySearchTerms.includes(query.filter.searchTerm) ) { this.historySearchTerms.push(query.filter.searchTerm); } let response; if (query.filter.dataSource.isHierarchyDataSource !== true) { // normal search response response = this.recordService.getResponse(query); } else { // hierarchy facet response response = this.hierarchyService.getResponse(query); } return await this.createSinaSearchResultSet(query, response); } private async createSinaSearchResultSet( query: SearchQuery, response: RecordResponse ): Promise { const searchResultSetItems = []; for (const record of response.resultsToDisplay) { searchResultSetItems.push(await this.createSinaSearchResultSetItem(query, record)); } // facets const facets = await this.createSinaFacets(query, response); // hierarchyNodePaths const hierarchyNodePaths = this.createSinaHierarchyNodePaths(query); return this.sina._createSearchResultSet({ query: query, title: "Search Results", items: searchResultSetItems, facets: facets, totalCount: response.totalCount, hierarchyNodePaths: hierarchyNodePaths, }); } private async createSinaSearchResultSetItem( query: SearchQuery | SuggestionQuery, record: Record ): Promise { const attributes = []; const titleAttributes = []; const detailAttributes = []; // single attributes const attributesMetadata = this.dataSourceService.getDataSource( record.dataSourceId ).attributesMetadata; attributesMetadata.forEach((attributeMetadata) => { const resultSetItemAttribute = this.createSinaSearchResultSetItemSingleAttribute( attributeMetadata as AttributeMetadata, record, query ); if (resultSetItemAttribute !== undefined) { attributes.push(resultSetItemAttribute); if ("Title" in resultSetItemAttribute.metadata.usage) { titleAttributes.push(resultSetItemAttribute); } if ("Detail" in resultSetItemAttribute.metadata.usage) { detailAttributes.push(resultSetItemAttribute); } } }); // group attributes -> postParseResultSetItem() // navigation target const defaultNavigationTarget = this.createSinaNavigationTarget(record); // hierarchyNodePaths const hierarchyNodePaths = this.createSinaHierarchyNodePathsForSearchResultSetItem( query as SearchQuery, record ); const searchResultSetItem = this.sina._createSearchResultSetItem({ dataSource: this.sina.getDataSource(record.dataSourceId), attributes: attributes, titleAttributes: titleAttributes, detailAttributes: detailAttributes, defaultNavigationTarget: defaultNavigationTarget, navigationTargets: [], hierarchyNodePaths: hierarchyNodePaths, }); // itemPostParser searchResultSetItem._private.allAttributesMap = attributes.reduce((map, attr) => { map[attr.id] = attr; return map; }, {}); // needed for postParseResultSetItem const itemPostParser = this.sina._createItemPostParser({ searchResultSetItem: searchResultSetItem, }); return await itemPostParser.postParseResultSetItem(); } private createSinaSearchResultSetItemSingleAttribute( singleAttributeMetadata: AttributeMetadata, record: Record, query: Query ): SearchResultSetItemAttribute { const sValue = record.valueMap[singleAttributeMetadata.id]?.stringValue; const rValue = record.valueMap[singleAttributeMetadata.id]?.rawValue; if (sValue === undefined) { return undefined; } const valueHighlighted = formatHighlightedValue(sValue, query.filter.searchTerm); const attribute = this.sina._createSearchResultSetItemAttribute({ id: singleAttributeMetadata.id, label: singleAttributeMetadata.label, metadata: singleAttributeMetadata, value: rValue, valueFormatted: undefined, valueHighlighted: valueHighlighted !== sValue ? valueHighlighted : undefined, isHighlighted: valueHighlighted !== sValue, groups: [], }); return attribute; } private createSinaNavigationTarget(record: Record): NavigationTarget { // For folder node records, create a navigation target using the same method as hierarchy attribute clicks if (record.isFolder) { const hDataSource = this.hierarchyService.getHierarchyDataSource(record.dataSourceId); if (hDataSource) { const hAttributeName = this.hierarchyService.getHierarchyAttributeName(hDataSource.id); const nodeValue = record.valueMap[hAttributeName]?.rawValue as string; // Get display name from title attributes (where hName is stored in createFolderRecord) const dataSource = this.sina.getDataSource(record.dataSourceId); const titleAttribute = dataSource.attributesMetadata.find((attr) => attr.usage?.Title); const nodeLabel = titleAttribute?.id ? (record.valueMap[titleAttribute.id]?.stringValue as string) : nodeValue || ""; if (nodeValue && hAttributeName) { // Use the same method as clicking hierarchy attribute values return this.sina.createStaticHierarchySearchNavigationTarget( nodeValue, nodeLabel, dataSource, nodeLabel, // navigation target label hAttributeName ); } } return undefined; } // hard code undefined navigation target // use for testing SearchText title, instead of SearchLink. if ( record.dataSourceId === "employees" && ["400000001", "400000002", "400000003"].includes(record.valueMap["EMPLOYEE_ID"].stringValue) ) { // susan summer, walter winter -> no navigation target return undefined; } if (record.dataSourceId === "products" && record.valueMap["PRODUCT_ID"].stringValue === "HT-1022") { // HT-1022: 32 gb digital assistant with high resolution color screen -> no navigation target return undefined; } if (record.dataSourceId === "bp" && record.valueMap["BP_ID"].stringValue === "100000001") { // 100000001: Becker Berlin -> no navigation target return undefined; } const ds = this.dataSourceService.getDataSource(record.dataSourceId); const template = ds.defaultNavigationTarget?.targetUrl; if (template === undefined) { return undefined; } const urlPrefix = "https://sap.com?"; const urlSuffix = template.replace( /{([^}]+)}/g, (match, attributeId) => record.valueMap[attributeId]?.stringValue || "" ); // replace {AttributeId} by record attribute stringValue return this.sina.createNavigationTarget({ text: ds.defaultNavigationTarget?.text, targetUrl: urlPrefix + urlSuffix, target: ds.defaultNavigationTarget?.target || "_self", }); } private async createSinaFacets( query: SearchQuery, response: RecordResponse ): Promise<(DataSourceResultSet | ChartResultSet)[]> { const facetsSina = []; const facets = this.facetService.createFacets(query.filter.dataSource.id, response.results)?.facets; if (!facets) { return []; } for (let i = 0; i < facets.length; i++) { const facet = facets[i] as Facet; if (facet.items === null) { // do nothing } else if (facet.type === FacetType.DataSource) { facetsSina.push(this.createSinaDataSourceFacet(query, facet)); } else { if ( query.filter.dataSource.type === query.sina.DataSourceType.Category || // ignore common attributes facets query.filter.dataSource.type === query.sina.DataSourceType.UserCategory // ignore attributes facets ) { continue; } facetsSina.push(this.createSinaChartFacet(query, facet)); } } return Promise.all(facetsSina); } private async createSinaDataSourceFacet(query: SearchQuery, facet: Facet): Promise { const dataSourceQuery = this.sina.createDataSourceQuery({ dataSource: query.filter.dataSource, filter: query.filter.clone(), // nlq: query.nlq, }); const items = []; for (let i = 0; i < facet.items.length; i++) { const item = facet.items[i]; // create filter (used when clicking on the item) let dataSource = this.sina.getDataSource(item.rawValueLow as string); if (!dataSource) { dataSource = this.sina.createDataSource({ type: this.sina.DataSourceType.Category, id: item.rawValueLow as string, label: item.description, }); } items.push( this.sina._createDataSourceResultSetItem({ dataSource: dataSource, dimensionValueFormatted: dataSource.labelPlural, measureValue: item.count, measureValueFormatted: item.count.toString(), }) ); } const resultSet = this.sina._createDataSourceResultSet({ title: query.filter.dataSource.label, items: items, query: dataSourceQuery, facetTotalCount: undefined, }); // init query with result set if (query instanceof SearchQuery) { dataSourceQuery._setResultSet(resultSet); } return Promise.resolve(resultSet); } private createSinaChartFacet( query: SearchQuery | ChartQuery, facet: Facet ): Promise { const dataSource = this.sina.getDataSource(query.filter.dataSource.id); const attributeId = facet.id; const metadata = dataSource.getAttributeMetadata(attributeId) as | AttributeMetadata | AttributeGroupMetadata; let chartQuery = query; if (query instanceof SearchQuery) { const filter = query.filter.clone(); filter.setDataSource(dataSource); // relevant only for common attribute facets filter.setRootCondition(query.filter.rootCondition.clone() as ComplexCondition); // changing ds removes condition chartQuery = this.sina.createChartQuery({ filter: filter, dimension: facet.id, // nlq: query.nlq, }); } const resultSet = this.sina._createChartResultSet({ title: metadata.label, items: this.createSinaChartResultSetItems(query, facet), query: chartQuery, facetTotalCount: 99999, // ToDo: Fill with total count of all facet items -> see getDataForPieChart of SearchFacetPieChart.ts }); // init query with result set if (query instanceof SearchQuery) { return chartQuery._setResultSet(resultSet); } return Promise.resolve(resultSet); } async chart(query: ChartQuery): Promise { await this.recordService.loadRecords(); const dataSource = this.sina.getDataSource(query.filter.dataSource.id); const attributeId = query.dimension; const metadata = dataSource.getAttributeMetadata(attributeId) as AttributeMetadata; const response = this.recordService.getResponse(query); const facetSet = this.facetService.createAttributeFacetSet(response.results, [metadata], query.top); try { return this.sina._createChartResultSet({ title: metadata.label, items: this.createSinaChartResultSetItems(query, facetSet.facets[0] as Facet), query: query, facetTotalCount: 99999, // ToDo: Fill with total count of all facet items -> see getDataForPieChart of SearchFacetPieChart.ts }); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { return this.sina._createChartResultSet({ title: metadata.label, items: [], query: query, facetTotalCount: 0, }); } } private createSinaChartResultSetItems(query: Query, facet: Facet): ChartResultSetItem[] { // TODO: // Search = "Search", // Eq = "Eq", // Ne = "Ne", // not equal // Gt = "Gt", // Lt = "Lt", // Ge = "Ge", // Le = "Le", // Co = "Co", // Contains only // Bw = "Bw", // Ew = "Ew", // End with // ChildOf = "ChildOf", // DescendantOf = "DescendantOf", const items = []; for (let i = 0; i < facet.items.length; i++) { const item = facet.items[i]; if (typeof item.rawValueHigh !== "string" && typeof item.rawValueLow !== "string") { // range facet item const conditions: SimpleCondition[] = []; conditions.push( this.sina.createSimpleCondition({ attribute: facet.id, operator: ComparisonOperator.Ge, value: item.rawValueLow, // isDynamicValue: isDynamicValueLow, }) ); conditions.push( this.sina.createSimpleCondition({ attribute: facet.id, operator: ComparisonOperator.Le, value: item.rawValueHigh, // isDynamicValue: isDynamicValueHigh, }) ); items.push( this.sina._createChartResultSetItem({ filterCondition: this.sina.createComplexCondition({ attributeLabel: facet.label, valueLabel: item.description, operator: LogicalOperator.And, conditions: conditions, }), dimensionValueFormatted: item.description, measureValue: item.count, measureValueFormatted: item.stringValueLow, }) ); } else { //single value facet item items.push( this.sina._createChartResultSetItem({ filterCondition: this.sina.createSimpleCondition({ attributeLabel: facet.label, attribute: facet.id, operator: ComparisonOperator.Eq, value: item.rawValueLow, valueLabel: item.description, // value: isDynamicValue ? item.rawValueLow : typeConverter.odata2Sina(metadata.type, item.rawValueLow), // isDynamicValue: isDynamicValue, }), dimensionValueFormatted: item.description, measureValue: item.count, measureValueFormatted: item.stringValueLow, }) ); } } return items; } private createSinaHierarchyNodePathsForSearchResultSetItem( query: SearchQuery, record: Record ): HierarchyNodePath[] { // Skip breadcrumb generation for folder node records if (record.isFolder) { return undefined; } let hierarchyNodePaths = undefined; if (query instanceof SearchQuery) { const hDataSource = this.hierarchyService.getHierarchyDataSource(record.dataSourceId); const hAttributeName = this.hierarchyService.getHierarchyAttributeName(hDataSource?.id); const hAttributeValue = record.valueMap[hAttributeName]?.stringValue; if (hAttributeValue !== undefined) { hierarchyNodePaths = this.createSinaHierarchyNodePaths(query, hAttributeValue); } } return hierarchyNodePaths; } private createSinaHierarchyNodePaths(query: Query, hAttributeValue?: string): HierarchyNodePath[] { const hCondition = this.hierarchyService.getHierarchySimpleCondition( query.filter.rootCondition, ComparisonOperator.DescendantOf ); if (hCondition === undefined) { return undefined; } const hDataSourceId = this.hierarchyService.getHierarchyDataSource(query.filter.dataSource.id)?.id; const rootPathObjects = this.hierarchyService.getRootPathObjects( hDataSourceId, (hAttributeValue || hCondition.value) as string ); const path = []; rootPathObjects.forEach((data, i) => { path.push({ id: data.value, label: data.label, isFirst: i === 0, isLast: i === rootPathObjects.length - 1, icon: data.icon, }); }); return [ this.sina.createHierarchyNodePath({ name: hCondition.attribute, path: path, }), ]; } async suggestion(query: SuggestionQuery): Promise { await this.recordService.loadRecords(); let response: DataSourceResponse | RecordResponse; let suggestions = []; switch (query.types[0]) { case SuggestionType.DataSource: response = this.dataSourceService.getResponse(query) as DataSourceResponse; suggestions = await this.createSinaDataSourceSuggestions(query, response); break; case SuggestionType.SearchTerm: response = this.recordService.getResponse(query) as RecordResponse; suggestions = await this.createSinaSearchTermSuggestions(query, response); break; case SuggestionType.Object: response = this.recordService.getResponse(query) as RecordResponse; suggestions = await this.createSinaObjectSuggestions(query, response); break; default: // SuggestionType.SearchTermAI // not implemented // SearchTermAndDataSource // check back-end supporting // App Suggestions // create app suggestions without searchableContent service } return this.sina._createSuggestionResultSet({ title: "Suggestions", query: query, items: suggestions, }); } private async createSinaDataSourceSuggestions( query: SuggestionQuery, response: DataSourceResponse ): Promise { const suggestions = []; response.results.map((dataSource) => { suggestions.push( this.sina._createDataSourceSuggestion({ calculationMode: this.sina.SuggestionCalculationMode.Data, dataSource: dataSource, label: formatHighlightedValue( dataSource.labelPlural || dataSource.label, query.filter.searchTerm ), }) ); }); return suggestions; } private async createSinaSearchTermSuggestions( query: SuggestionQuery, response: RecordResponse ): Promise { const suggestions = []; response.resultsToDisplay.forEach((record) => { const filter = query.filter.clone(); const dataSource = this.sina.getDataSource(record.dataSourceId); filter.setDataSource(dataSource); suggestions.push( this.sina._createSearchTermSuggestion({ calculationMode: this.sina.SuggestionCalculationMode.Data, searchTerm: getMatchedStringValues(record.stringValues, query.filter.searchTerm)[0], filter: query.filter, label: formatHighlightedValue( getMatchedStringValues(record.stringValues, query.filter.searchTerm)[0], query.filter.searchTerm ), childSuggestions: [ this.sina._createSearchTermAndDataSourceSuggestion({ calculationMode: this.sina.SuggestionCalculationMode.Data, searchTerm: getMatchedStringValues( record.stringValues, query.filter.searchTerm )[0], filter, dataSource, label: formatHighlightedValue( getMatchedStringValues(record.stringValues, query.filter.searchTerm)[0], query.filter.searchTerm ), }), ], }) ); }); // History Search Term Suggestions if (!isStarString(query.filter.searchTerm)) { getMatchedStringValues(this.historySearchTerms, query.filter.searchTerm).forEach((searchTerm) => { suggestions.push( this.sina._createSearchTermSuggestion({ calculationMode: this.sina.SuggestionCalculationMode.History, searchTerm: searchTerm, filter: query.filter, label: formatHighlightedValue(searchTerm, query.filter.searchTerm), }) ); }); } return suggestions; } private async createSinaObjectSuggestions( query: SuggestionQuery, response: RecordResponse ): Promise { const suggestions = []; for (const record of response.resultsToDisplay) { suggestions.push( this.sina._createObjectSuggestion({ calculationMode: this.sina.SuggestionCalculationMode.Data, label: "", // ? // searchTerm: filter.searchTerm, // filter: filter, object: await this.createSinaSearchResultSetItem(query, record), }) ); } return suggestions; } }