1import { OptionsPaneItemDescriptor } from '../OptionsPaneItemDescriptor';
2import { OptionsPaneCategoryDescriptor } from '../OptionsPaneCategoryDescriptor';
3
4export interface OptionSearchResults {
5  optionHits: OptionsPaneItemDescriptor[];
6  overrideHits: OptionsPaneCategoryDescriptor[];
7  totalCount: number;
8}
9
10export class OptionSearchEngine {
11  constructor(
12    private categories: OptionsPaneCategoryDescriptor[],
13    private overrides: OptionsPaneCategoryDescriptor[]
14  ) {}
15
16  search(query: string): OptionSearchResults {
17    const searchRegex = new RegExp(query, 'i');
18
19    const optionHits = this.collectHits(this.categories, searchRegex, []);
20    const sortedHits = optionHits.sort(compareHit).map((x) => x.item);
21
22    const overrideHits = this.collectHits(this.overrides, searchRegex, []);
23    const sortedOverridesHits = overrideHits.sort(compareHit).map((x) => x.item);
24
25    return {
26      optionHits: sortedHits,
27      overrideHits: this.buildOverrideHitCategories(sortedOverridesHits),
28      totalCount: this.getAllOptionsCount(this.categories),
29    };
30  }
31
32  private collectHits(categories: OptionsPaneCategoryDescriptor[], searchRegex: RegExp, hits: SearchHit[]) {
33    for (const category of categories) {
34      const categoryNameMatch = searchRegex.test(category.props.title);
35
36      for (const item of category.items) {
37        if (searchRegex.test(item.props.title)) {
38          hits.push({ item: item, rank: 1 });
39          continue;
40        }
41        if (item.props.description && searchRegex.test(item.props.description)) {
42          hits.push({ item: item, rank: 2 });
43          continue;
44        }
45        if (categoryNameMatch) {
46          hits.push({ item: item, rank: 3 });
47        }
48      }
49
50      if (category.categories.length > 0) {
51        this.collectHits(category.categories, searchRegex, hits);
52      }
53    }
54
55    return hits;
56  }
57
58  getAllOptionsCount(categories: OptionsPaneCategoryDescriptor[]) {
59    var total = 0;
60
61    for (const category of categories) {
62      total += category.items.length;
63
64      if (category.categories.length > 0) {
65        total += this.getAllOptionsCount(category.categories);
66      }
67    }
68
69    return total;
70  }
71
72  buildOverrideHitCategories(hits: OptionsPaneItemDescriptor[]): OptionsPaneCategoryDescriptor[] {
73    const categories: Record<string, OptionsPaneCategoryDescriptor> = {};
74
75    for (const hit of hits) {
76      let category = categories[hit.parent.props.title];
77
78      if (!category) {
79        category = categories[hit.parent.props.title] = new OptionsPaneCategoryDescriptor(hit.parent.props);
80        // Add matcher item as that should always be shown
81        category.addItem(hit.parent.items[0]);
82      }
83
84      // Prevent adding matcher twice since it's automatically added for every override
85      if (category.items[0].props.title !== hit.props.title) {
86        category.addItem(hit);
87      }
88    }
89
90    return Object.values(categories);
91  }
92}
93
94interface SearchHit {
95  item: OptionsPaneItemDescriptor;
96  rank: number;
97}
98
99function compareHit(left: SearchHit, right: SearchHit) {
100  return left.rank - right.rank;
101}
102