diff --git a/packages/legend-server-showcase-deployment/scripts/setup.js b/packages/legend-server-showcase-deployment/scripts/setup.js index 84383759ea..b29c7bc2ec 100644 --- a/packages/legend-server-showcase-deployment/scripts/setup.js +++ b/packages/legend-server-showcase-deployment/scripts/setup.js @@ -36,12 +36,14 @@ writeFileSync( port: 9003, datasources: // NOTE: if you want to test with local source, use this - // { + // [{ // path: './data/metadata.json', - // }, - { - url: 'https://legend.finos.org/showcases/data.json', - }, + // }], + [ + { + url: 'https://legend.finos.org/showcases/data.json', + }, + ], }, undefined, 2, diff --git a/packages/legend-server-showcase-deployment/src/ShowcaseRegistry.ts b/packages/legend-server-showcase-deployment/src/ShowcaseRegistry.ts index 55b99ab9bf..4315d09f8b 100644 --- a/packages/legend-server-showcase-deployment/src/ShowcaseRegistry.ts +++ b/packages/legend-server-showcase-deployment/src/ShowcaseRegistry.ts @@ -17,7 +17,11 @@ import { get } from 'https'; import { readFileSync } from 'fs'; import { Showcase, type ShowcaseMetadata } from '@finos/legend-server-showcase'; -import { type PlainObject } from '@finos/legend-shared'; +import { + ESM__FuzzySearchEngine, + FuzzySearchEngine, + type PlainObject, +} from '@finos/legend-shared'; async function fetchExternalLinkSiteData(url: string): Promise { return new Promise((resolve, reject) => { @@ -56,6 +60,23 @@ export type ShowcaseRegistryConfig = { }[]; }; +type TextMatch = { + path: string; + // NOTE: we don't allow doing multi-line text search + line: number; + startColumn: number; + endColumn: number; + previewStartLine: number; + previewText: string; +}; + +type TextSearchResult = { + showcases: string[]; + textMatches: TextMatch[]; +}; + +const PREVIEW_LINE_RANGE = 2; + export class ShowcaseRegistry { // NOTE: maintain these to improve performance private readonly RAW__metadata: PlainObject[] = []; @@ -65,9 +86,52 @@ export class ShowcaseRegistry { >(); private readonly showcasesIndex = new Map(); + private readonly showcaseSearchEngine: FuzzySearchEngine; + // private constructor to enforce singleton private constructor() { - // private constructor to enforce singleton + // NOTE: due to the way we export the constructor of `FuzzySearchEngine`, when we run this with ESM + // we can remove this workaround once Fuse supports ESM + // See https://github.com/krisk/Fuse/pull/727 + const FuzzySearchEngineConstructor = + // eslint-disable-next-line no-process-env + process.env.NODE_ENV === 'development' + ? ESM__FuzzySearchEngine + : FuzzySearchEngine; + this.showcaseSearchEngine = new FuzzySearchEngineConstructor([], { + includeScore: true, + // NOTE: we must not sort/change the order in the grid since + // we want to ensure the element row is on top + shouldSort: false, + // Ignore location when computing the search score + // See https://fusejs.io/concepts/scoring-theory.html + ignoreLocation: true, + // This specifies the point the search gives up + // `0.0` means exact match where `1.0` would match anything + // We set a relatively low threshold to filter out irrelevant results + threshold: 0.2, + keys: [ + { + name: 'title', + weight: 5, + }, + { + name: 'description', + weight: 3, + }, + { + name: 'path', + weight: 2, + }, + { + name: 'documentation', + weight: 1, + }, + ], + // extended search allows for exact word match through single quote + // See https://fusejs.io/examples.html#extended-search + useExtendedSearch: true, + }); } static async initialize( @@ -94,6 +158,10 @@ export class ShowcaseRegistry { }), ); + registry.showcaseSearchEngine.setCollection( + Array.from(registry.showcasesIndex.values()), + ); + return registry; } @@ -104,4 +172,52 @@ export class ShowcaseRegistry { getShowcase(path: string): PlainObject | undefined { return this.RAW__showcaseIndex.get(path); } + + search(searchText: string): TextSearchResult { + const textMatches: TextMatch[] = []; + // NOTE: for text search, we only support case-insensitive search now + const lowerCaseSearchText = searchText.toLowerCase(); + for (const showcase of this.showcasesIndex.values()) { + const code = showcase.code; + const lines = code.split('\n'); + lines.forEach((line, lineIdx) => { + const lowerCaseLine = line.toLowerCase(); + let fromIdx = 0; + let currentMatchIdx = lowerCaseLine.indexOf( + lowerCaseSearchText, + fromIdx, + ); + while (currentMatchIdx !== -1) { + const previewTextStartLineIdx = Math.max( + lineIdx - PREVIEW_LINE_RANGE, + 0, + ); + const previewTextEndLineIdx = Math.min( + lineIdx + 1 + PREVIEW_LINE_RANGE, + lines.length - 1, + ); + const previewText = lines + .slice(previewTextStartLineIdx, previewTextEndLineIdx) + .join('\n'); + const match: TextMatch = { + path: showcase.path, + line: lineIdx + 1, + startColumn: currentMatchIdx + 1, + endColumn: currentMatchIdx + 1 + lowerCaseSearchText.length, + previewStartLine: previewTextStartLineIdx + 1, + previewText, + }; + fromIdx = currentMatchIdx + lowerCaseSearchText.length; + currentMatchIdx = lowerCaseLine.indexOf(lowerCaseSearchText, fromIdx); + textMatches.push(match); + } + }); + } + return { + showcases: Array.from( + this.showcaseSearchEngine.search(searchText).values(), + ).map((result) => result.item.path), + textMatches, + }; + } } diff --git a/packages/legend-server-showcase-deployment/src/ShowcaseRegistryServer.ts b/packages/legend-server-showcase-deployment/src/ShowcaseRegistryServer.ts index 965877a024..7b97b97714 100644 --- a/packages/legend-server-showcase-deployment/src/ShowcaseRegistryServer.ts +++ b/packages/legend-server-showcase-deployment/src/ShowcaseRegistryServer.ts @@ -26,6 +26,12 @@ interface GetShowcaseRequest extends RequestGenericInterface { }; } +interface TextSearchRequest extends RequestGenericInterface { + Querystring: { + searchText: string; + }; +} + export const configureShowcaseRegistryServer = async ( server: FastifyInstance, config: ShowcaseRegistryConfig & { @@ -53,7 +59,12 @@ export const configureShowcaseRegistryServer = async ( }, ); - server.get(`${baseUrl}/showcase/search`, async (request, reply) => { - // do nothing - }); + server.get( + `${baseUrl}/showcases/search`, + async (request, reply) => { + const { searchText } = request.query; + + await reply.send(registry.search(searchText)); + }, + ); }; diff --git a/packages/legend-shared/src/search/FuzzySearch.ts b/packages/legend-shared/src/search/FuzzySearch.ts index 45a175e4bf..824421edc3 100644 --- a/packages/legend-shared/src/search/FuzzySearch.ts +++ b/packages/legend-shared/src/search/FuzzySearch.ts @@ -29,5 +29,10 @@ import Fuse from './CJS__Fuse.cjs'; import type { default as FuseType } from 'fuse.js'; export const FuzzySearchEngine = Fuse.Fuse.default; +// NOTE: due to the way we export the constructor of `FuzzySearchEngine`, when we run this with ESM +// we can remove this workaround once Fuse supports ESM +// See https://github.com/krisk/Fuse/pull/727 +export const ESM__FuzzySearchEngine = + Fuse.Fuse as unknown as typeof FuzzySearchEngine; // eslint-disable-next-line @typescript-eslint/no-redeclare export type FuzzySearchEngine = FuseType.default;