@internetarchive/collection-browser 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/src/app-root.js +1 -1
  2. package/dist/src/app-root.js.map +1 -1
  3. package/dist/src/collection-browser.d.ts +40 -3
  4. package/dist/src/collection-browser.js +214 -58
  5. package/dist/src/collection-browser.js.map +1 -1
  6. package/dist/src/collection-facets/more-facets-content.d.ts +3 -2
  7. package/dist/src/collection-facets/more-facets-content.js +6 -2
  8. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  9. package/dist/src/collection-facets.d.ts +3 -2
  10. package/dist/src/collection-facets.js +6 -2
  11. package/dist/src/collection-facets.js.map +1 -1
  12. package/dist/src/models.d.ts +9 -0
  13. package/dist/src/models.js +8 -0
  14. package/dist/src/models.js.map +1 -1
  15. package/dist/src/restoration-state-handler.d.ts +0 -1
  16. package/dist/src/restoration-state-handler.js +4 -4
  17. package/dist/src/restoration-state-handler.js.map +1 -1
  18. package/dist/src/sort-filter-bar/alpha-bar.d.ts +3 -0
  19. package/dist/src/sort-filter-bar/alpha-bar.js +32 -13
  20. package/dist/src/sort-filter-bar/alpha-bar.js.map +1 -1
  21. package/dist/src/sort-filter-bar/sort-filter-bar.d.ts +2 -1
  22. package/dist/src/sort-filter-bar/sort-filter-bar.js +7 -0
  23. package/dist/src/sort-filter-bar/sort-filter-bar.js.map +1 -1
  24. package/dist/src/tiles/image-block.js +0 -1
  25. package/dist/src/tiles/image-block.js.map +1 -1
  26. package/dist/test/collection-browser.test.js +81 -9
  27. package/dist/test/collection-browser.test.js.map +1 -1
  28. package/dist/test/collection-facets/more-facets-content.test.js +2 -2
  29. package/dist/test/collection-facets/more-facets-content.test.js.map +1 -1
  30. package/dist/test/mocks/mock-search-responses.d.ts +2 -0
  31. package/dist/test/mocks/mock-search-responses.js +70 -0
  32. package/dist/test/mocks/mock-search-responses.js.map +1 -1
  33. package/dist/test/mocks/mock-search-service.js +5 -1
  34. package/dist/test/mocks/mock-search-service.js.map +1 -1
  35. package/dist/test/restoration-state-handler.test.js +0 -1
  36. package/dist/test/restoration-state-handler.test.js.map +1 -1
  37. package/dist/test/sort-filter-bar/alpha-bar.test.d.ts +1 -0
  38. package/dist/test/sort-filter-bar/alpha-bar.test.js +44 -0
  39. package/dist/test/sort-filter-bar/alpha-bar.test.js.map +1 -0
  40. package/dist/test/sort-filter-bar/sort-filter-bar.test.js +32 -0
  41. package/dist/test/sort-filter-bar/sort-filter-bar.test.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/app-root.ts +1 -1
  44. package/src/collection-browser.ts +273 -57
  45. package/src/collection-facets/more-facets-content.ts +6 -2
  46. package/src/collection-facets.ts +6 -2
  47. package/src/models.ts +15 -0
  48. package/src/restoration-state-handler.ts +7 -5
  49. package/src/sort-filter-bar/alpha-bar.ts +26 -9
  50. package/src/sort-filter-bar/sort-filter-bar.ts +9 -0
  51. package/src/tiles/image-block.ts +0 -1
  52. package/test/collection-browser.test.ts +90 -10
  53. package/test/collection-facets/more-facets-content.test.ts +2 -2
  54. package/test/mocks/mock-search-responses.ts +78 -0
  55. package/test/mocks/mock-search-service.ts +6 -0
  56. package/test/restoration-state-handler.test.ts +0 -3
  57. package/test/sort-filter-bar/alpha-bar.test.ts +52 -0
  58. package/test/sort-filter-bar/sort-filter-bar.test.ts +44 -0
@@ -17,6 +17,10 @@ import type {
17
17
  } from '@internetarchive/infinite-scroller';
18
18
  import {
19
19
  Aggregation,
20
+ Bucket,
21
+ FilterConstraint,
22
+ FilterMap,
23
+ FilterMapBuilder,
20
24
  SearchParams,
21
25
  SearchResult,
22
26
  SearchServiceInterface,
@@ -47,6 +51,9 @@ import {
47
51
  CollectionDisplayMode,
48
52
  FacetOption,
49
53
  FacetBucket,
54
+ PrefixFilterType,
55
+ PrefixFilterCounts,
56
+ prefixFilterAggregationKeys,
50
57
  } from './models';
51
58
  import {
52
59
  RestorationStateHandlerInterface,
@@ -92,8 +99,6 @@ export class CollectionBrowser
92
99
 
93
100
  @property({ type: String }) sortDirection: SortDirection | null = null;
94
101
 
95
- @property({ type: String }) dateRangeQueryClause?: string;
96
-
97
102
  @property({ type: Number }) pageSize = 50;
98
103
 
99
104
  @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
@@ -177,6 +182,10 @@ export class CollectionBrowser
177
182
 
178
183
  @state() private placeholderType: PlaceholderType = null;
179
184
 
185
+ @state() private prefixFilterCountMap: Partial<
186
+ Record<PrefixFilterType, PrefixFilterCounts>
187
+ > = {};
188
+
180
189
  @query('#content-container') private contentContainer!: HTMLDivElement;
181
190
 
182
191
  private languageCodeHandler = new LanguageCodeHandler();
@@ -323,6 +332,10 @@ export class CollectionBrowser
323
332
  }
324
333
 
325
334
  private get collectionBrowserTemplate() {
335
+ const shouldShowSearching =
336
+ this.searchResultsLoading || this.totalResults === undefined;
337
+ const resultsCount = this.totalResults?.toLocaleString();
338
+ const resultsLabel = this.totalResults === 1 ? 'Result' : 'Results';
326
339
  return html`<div
327
340
  id="left-column"
328
341
  class="column${this.isResizeToMobile ? ' preload' : ''}"
@@ -331,12 +344,10 @@ export class CollectionBrowser
331
344
  ${this.mobileView ? this.mobileFacetsTemplate : nothing}
332
345
  <div id="results-total">
333
346
  <span id="big-results-count">
334
- ${this.totalResults !== undefined
335
- ? this.totalResults.toLocaleString()
336
- : '-'}
347
+ ${shouldShowSearching ? html`Searching&hellip;` : resultsCount}
337
348
  </span>
338
349
  <span id="big-results-label">
339
- ${this.totalResults === 1 ? 'Result' : 'Results'}
350
+ ${shouldShowSearching ? nothing : resultsLabel}
340
351
  </span>
341
352
  </div>
342
353
  </div>
@@ -376,6 +387,7 @@ export class CollectionBrowser
376
387
  .displayMode=${this.displayMode}
377
388
  .selectedTitleFilter=${this.selectedTitleFilter}
378
389
  .selectedCreatorFilter=${this.selectedCreatorFilter}
390
+ .prefixFilterCountMap=${this.prefixFilterCountMap}
379
391
  .resizeObserver=${this.resizeObserver}
380
392
  @sortChanged=${this.userChangedSort}
381
393
  @displayModeChanged=${this.displayModeChanged}
@@ -423,6 +435,9 @@ export class CollectionBrowser
423
435
 
424
436
  if (!sortField) return;
425
437
  this.sortParam = { field: sortField, direction: this.sortDirection };
438
+
439
+ // Lazy-load the alphabet counts for title/creator sort bar as needed
440
+ this.updatePrefixFiltersForCurrentSort();
426
441
  }
427
442
 
428
443
  private displayModeChanged(
@@ -541,7 +556,8 @@ export class CollectionBrowser
541
556
  .collectionNameCache=${this.collectionNameCache}
542
557
  .languageCodeHandler=${this.languageCodeHandler}
543
558
  .showHistogramDatePicker=${this.showHistogramDatePicker}
544
- .fullQuery=${this.fullQuery}
559
+ .query=${this.filteredQuery}
560
+ .filterMap=${this.filterMap}
545
561
  .modalManager=${this.modalManager}
546
562
  ?collapsableFacets=${this.mobileView}
547
563
  ?facetsLoading=${this.facetDataLoading}
@@ -576,21 +592,6 @@ export class CollectionBrowser
576
592
  `;
577
593
  }
578
594
 
579
- private get queryDebuggingTemplate() {
580
- return html`
581
- <div>
582
- <ul>
583
- <li>Base Query: ${this.baseQuery}</li>
584
- <li>Facet Query: ${this.facetQuery}</li>
585
- <li>Sort Filter Query: ${this.sortFilterQueries}</li>
586
- <li>Date Range Query: ${this.dateRangeQueryClause}</li>
587
- <li>Sort: ${this.sortParam?.field} ${this.sortParam?.direction}</li>
588
- <li>Full Query: ${this.fullQuery}</li>
589
- </ul>
590
- </div>
591
- `;
592
- }
593
-
594
595
  private histogramDateRangeUpdated(
595
596
  e: CustomEvent<{
596
597
  minDate: string;
@@ -598,17 +599,21 @@ export class CollectionBrowser
598
599
  }>
599
600
  ) {
600
601
  const { minDate, maxDate } = e.detail;
601
-
602
602
  [this.minSelectedDate, this.maxSelectedDate] = [minDate, maxDate];
603
- this.dateRangeQueryClause = `year:[${minDate} TO ${maxDate}]`;
604
603
 
605
- if (this.dateRangeQueryClause) {
606
- this.analyticsHandler?.sendEvent({
607
- category: this.searchContext,
608
- action: analyticsActions.histogramChanged,
609
- label: this.dateRangeQueryClause,
610
- });
604
+ this.analyticsHandler?.sendEvent({
605
+ category: this.searchContext,
606
+ action: analyticsActions.histogramChanged,
607
+ label: this.dateRangeQueryClause,
608
+ });
609
+ }
610
+
611
+ private get dateRangeQueryClause() {
612
+ if (!this.minSelectedDate || !this.maxSelectedDate) {
613
+ return undefined;
611
614
  }
615
+
616
+ return `year:[${this.minSelectedDate} TO ${this.maxSelectedDate}]`;
612
617
  }
613
618
 
614
619
  firstUpdated(): void {
@@ -638,13 +643,22 @@ export class CollectionBrowser
638
643
  changed.has('baseQuery') ||
639
644
  changed.has('titleQuery') ||
640
645
  changed.has('creatorQuery') ||
641
- changed.has('dateRangeQueryClause') ||
646
+ changed.has('minSelectedDate') ||
647
+ changed.has('maxSelectedDate') ||
642
648
  changed.has('sortParam') ||
643
649
  changed.has('selectedFacets') ||
644
650
  changed.has('searchService')
645
651
  ) {
646
652
  this.handleQueryChange();
647
653
  }
654
+ if (
655
+ changed.has('baseQuery') ||
656
+ changed.has('minSelectedDate') ||
657
+ changed.has('maxSelectedDate') ||
658
+ changed.has('selectedFacets')
659
+ ) {
660
+ this.refreshLetterCounts();
661
+ }
648
662
  if (changed.has('selectedSort') || changed.has('sortDirection')) {
649
663
  const prevSortDirection = changed.get('sortDirection') as SortDirection;
650
664
  this.sendSortByAnalytics(prevSortDirection);
@@ -775,6 +789,9 @@ export class CollectionBrowser
775
789
  this.previousQueryKey = this.pageFetchQueryKey;
776
790
 
777
791
  this.dataSource = {};
792
+ this.totalResults = undefined;
793
+ this.aggregations = undefined;
794
+ this.fullYearsHistogramAggregation = undefined;
778
795
  this.pageFetchesInProgress = {};
779
796
  this.endOfDataReached = false;
780
797
  this.pagesToRender = this.initialPageNumber;
@@ -830,7 +847,6 @@ export class CollectionBrowser
830
847
  this.baseQuery = restorationState.baseQuery;
831
848
  this.titleQuery = restorationState.titleQuery;
832
849
  this.creatorQuery = restorationState.creatorQuery;
833
- this.dateRangeQueryClause = restorationState.dateRangeQueryClause;
834
850
  this.sortParam = restorationState.sortParam ?? null;
835
851
  this.currentPage = restorationState.currentPage ?? 1;
836
852
  this.minSelectedDate = restorationState.minSelectedDate;
@@ -850,7 +866,6 @@ export class CollectionBrowser
850
866
  selectedFacets: this.selectedFacets ?? defaultSelectedFacets,
851
867
  baseQuery: this.baseQuery,
852
868
  currentPage: this.currentPage,
853
- dateRangeQueryClause: this.dateRangeQueryClause,
854
869
  titleQuery: this.titleQuery,
855
870
  creatorQuery: this.creatorQuery,
856
871
  minSelectedDate: this.minSelectedDate,
@@ -867,6 +882,84 @@ export class CollectionBrowser
867
882
  this.searchResultsLoading = false;
868
883
  }
869
884
 
885
+ private get filterMap(): FilterMap {
886
+ const builder = new FilterMapBuilder();
887
+
888
+ // Add the date range, if applicable
889
+ if (this.minSelectedDate) {
890
+ builder.addFilter(
891
+ 'year',
892
+ this.minSelectedDate,
893
+ FilterConstraint.GREATER_OR_EQUAL
894
+ );
895
+ }
896
+ if (this.maxSelectedDate) {
897
+ builder.addFilter(
898
+ 'year',
899
+ this.maxSelectedDate,
900
+ FilterConstraint.LESS_OR_EQUAL
901
+ );
902
+ }
903
+
904
+ // Add any selected facets
905
+ if (this.selectedFacets) {
906
+ for (const [facetName, facetValues] of Object.entries(
907
+ this.selectedFacets
908
+ )) {
909
+ const { name, values } = this.prepareFacetForFetch(
910
+ facetName,
911
+ facetValues
912
+ );
913
+ for (const [value, bucket] of Object.entries(values)) {
914
+ let constraint;
915
+ if (bucket.state === 'selected') {
916
+ constraint = FilterConstraint.INCLUDE;
917
+ } else if (bucket.state === 'hidden') {
918
+ constraint = FilterConstraint.EXCLUDE;
919
+ }
920
+
921
+ if (constraint) {
922
+ builder.addFilter(name, value, constraint);
923
+ }
924
+ }
925
+ }
926
+ }
927
+
928
+ const filterMap = builder.build();
929
+
930
+ // TEMP: At present, the backend search engine incorrectly returns 0 results if
931
+ // the _first_ language filter contains a space, so let's try to avoid that if possible.
932
+ if (filterMap.language) {
933
+ for (const [value, constraint] of Object.entries(filterMap.language)) {
934
+ if (value.includes(' ')) {
935
+ // Delete and re-add this filter to make it the last one on the parent object
936
+ // (Technically this isn't in the standard, but most browser impls output
937
+ // object keys in the order they were added.)
938
+ delete filterMap.language[value];
939
+ filterMap.language[value] = constraint;
940
+ } else {
941
+ // As soon as we reach one without a space, we're done
942
+ break;
943
+ }
944
+ }
945
+ }
946
+
947
+ return filterMap;
948
+ }
949
+
950
+ /** The base query joined with any title/creator letter filters */
951
+ private get filteredQuery(): string | undefined {
952
+ if (!this.baseQuery) return undefined;
953
+ let filteredQuery = this.baseQuery;
954
+
955
+ const { sortFilterQueries } = this;
956
+ if (sortFilterQueries) {
957
+ filteredQuery += ` AND ${sortFilterQueries}`;
958
+ }
959
+
960
+ return filteredQuery;
961
+ }
962
+
870
963
  /** The full query, including year facets and date range clauses */
871
964
  private get fullQuery(): string | undefined {
872
965
  if (!this.baseQuery) return undefined;
@@ -886,6 +979,22 @@ export class CollectionBrowser
886
979
  return fullQuery;
887
980
  }
888
981
 
982
+ /** The full query without any title/creator letter filters */
983
+ private get fullQueryWithoutAlphaFilters(): string | undefined {
984
+ if (!this.baseQuery) return undefined;
985
+ let fullQuery = this.baseQuery;
986
+
987
+ const { facetQuery, dateRangeQueryClause } = this;
988
+
989
+ if (facetQuery) {
990
+ fullQuery += ` AND ${facetQuery}`;
991
+ }
992
+ if (dateRangeQueryClause) {
993
+ fullQuery += ` AND ${dateRangeQueryClause}`;
994
+ }
995
+ return fullQuery;
996
+ }
997
+
889
998
  /** The full query without any year facets or date range clauses */
890
999
  private get fullQueryWithoutDates(): string | undefined {
891
1000
  if (!this.baseQuery) return undefined;
@@ -951,29 +1060,57 @@ export class CollectionBrowser
951
1060
  facetName: string,
952
1061
  facetValues: Record<string, FacetBucket>
953
1062
  ): string {
954
- const facetEntries = Object.entries(facetValues);
1063
+ const { name: facetQueryName, values } = this.prepareFacetForFetch(
1064
+ facetName,
1065
+ facetValues
1066
+ );
1067
+ const facetEntries = Object.entries(values);
955
1068
  if (facetEntries.length === 0) return '';
956
1069
 
957
- const facetQueryName =
958
- facetName === 'lending' ? 'lending___status' : facetName;
959
1070
  const facetValuesArray: string[] = [];
960
-
961
1071
  for (const [key, facetData] of facetEntries) {
962
1072
  const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
1073
+ facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
1074
+ }
1075
+
1076
+ const valueQuery = facetValuesArray.join(` OR `);
1077
+ return `${facetQueryName}:(${valueQuery})`;
1078
+ }
963
1079
 
964
- if (facetName === 'language') {
1080
+ /**
1081
+ * Handles some special pre-request normalization steps for certain facet types
1082
+ * that require them.
1083
+ *
1084
+ * @param facetName The name of the facet type (e.g., 'language')
1085
+ * @param facetValues An array of values for that facet type
1086
+ */
1087
+ private prepareFacetForFetch(
1088
+ facetName: string,
1089
+ facetValues: Record<string, FacetBucket>
1090
+ ): { name: string; values: Record<string, FacetBucket> } {
1091
+ let [normalizedName, normalizedValues] = [facetName, facetValues];
1092
+
1093
+ // The full "search engine" name of the lending field is "lending___status"
1094
+ if (facetName === 'lending') {
1095
+ normalizedName = 'lending___status';
1096
+ }
1097
+
1098
+ // Language codes like "en-US|en-GB|en" need to be broken apart into individual values
1099
+ if (facetName === 'language') {
1100
+ normalizedValues = {};
1101
+ for (const [facetValue, facetData] of Object.entries(facetValues)) {
965
1102
  const languages =
966
- this.languageCodeHandler.getCodeArrayFromCodeString(key);
967
- for (const language of languages) {
968
- facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
1103
+ this.languageCodeHandler.getCodeArrayFromCodeString(facetValue);
1104
+ for (const lang of languages) {
1105
+ normalizedValues[lang] = { ...facetData };
969
1106
  }
970
- } else {
971
- facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
972
1107
  }
973
1108
  }
974
1109
 
975
- const valueQuery = facetValuesArray.join(` OR `);
976
- return `${facetQueryName}:(${valueQuery})`;
1110
+ return {
1111
+ name: normalizedName,
1112
+ values: normalizedValues,
1113
+ };
977
1114
  }
978
1115
 
979
1116
  /**
@@ -1018,11 +1155,12 @@ export class CollectionBrowser
1018
1155
  }
1019
1156
 
1020
1157
  private async fetchFacets() {
1021
- if (!this.fullQuery) return;
1158
+ if (!this.filteredQuery) return;
1022
1159
 
1023
1160
  const params: SearchParams = {
1024
- query: this.fullQuery,
1161
+ query: this.filteredQuery,
1025
1162
  rows: 0,
1163
+ filters: this.filterMap,
1026
1164
  // Fetch a few extra buckets beyond the 6 we show, in case some get suppressed
1027
1165
  aggregationsSize: 10,
1028
1166
  // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
@@ -1132,7 +1270,14 @@ export class CollectionBrowser
1132
1270
  }
1133
1271
 
1134
1272
  /**
1135
- * The query key is a string that uniquely identifies the current query
1273
+ * The query key is a string that uniquely identifies the current search.
1274
+ * It consists of:
1275
+ * - The current base query
1276
+ * - The current search type
1277
+ * - Any currently-applied facets
1278
+ * - Any currently-applied date range
1279
+ * - Any currently-applied prefix filters
1280
+ * - The current sort options
1136
1281
  *
1137
1282
  * This lets us keep track of queries so we don't persist data that's
1138
1283
  * no longer relevant.
@@ -1145,7 +1290,7 @@ export class CollectionBrowser
1145
1290
  private pageFetchesInProgress: Record<string, Set<number>> = {};
1146
1291
 
1147
1292
  async fetchPage(pageNumber: number) {
1148
- if (!this.fullQuery) return;
1293
+ if (!this.filteredQuery) return;
1149
1294
 
1150
1295
  // if we already have data, don't fetch again
1151
1296
  if (this.dataSource[pageNumber]) return;
@@ -1162,10 +1307,11 @@ export class CollectionBrowser
1162
1307
 
1163
1308
  const sortParams = this.sortParam ? [this.sortParam] : [];
1164
1309
  const params: SearchParams = {
1165
- query: this.fullQuery,
1310
+ query: this.filteredQuery,
1166
1311
  page: pageNumber,
1167
1312
  rows: this.pageSize,
1168
1313
  sort: sortParams,
1314
+ filters: this.filterMap,
1169
1315
  aggregations: { omit: true },
1170
1316
  };
1171
1317
  const searchResponse = await this.searchService?.search(
@@ -1203,7 +1349,7 @@ export class CollectionBrowser
1203
1349
  }
1204
1350
  }
1205
1351
  const queryChangedSinceFetch =
1206
- searchQuery !== this.fullQuery || sortChanged;
1352
+ searchQuery !== this.filteredQuery || sortChanged;
1207
1353
  if (queryChangedSinceFetch) return;
1208
1354
 
1209
1355
  const { results } = success.response;
@@ -1211,12 +1357,13 @@ export class CollectionBrowser
1211
1357
  this.preloadCollectionNames(results);
1212
1358
  this.updateDataSource(pageNumber, results);
1213
1359
  }
1360
+
1361
+ // When we reach the end of the data, we can set the infinite scroller's
1362
+ // item count to the real total number of results (rather than the
1363
+ // temporary estimates based on pages rendered so far).
1214
1364
  if (results.length < this.pageSize) {
1215
1365
  this.endOfDataReached = true;
1216
- // this updates the infinite scroller to show the actual size
1217
- if (this.infiniteScroller) {
1218
- this.infiniteScroller.itemCount = this.actualTileCount;
1219
- }
1366
+ this.infiniteScroller.itemCount = this.totalResults;
1220
1367
  }
1221
1368
  this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber);
1222
1369
  this.searchResultsLoading = false;
@@ -1320,6 +1467,73 @@ export class CollectionBrowser
1320
1467
  }
1321
1468
  }
1322
1469
 
1470
+ /** Fetches the aggregation buckets for the given prefix filter type. */
1471
+ private async fetchPrefixFilterBuckets(
1472
+ filterType: PrefixFilterType
1473
+ ): Promise<Bucket[]> {
1474
+ if (!this.fullQueryWithoutAlphaFilters) return [];
1475
+
1476
+ const filterAggregationKey = prefixFilterAggregationKeys[filterType];
1477
+ const params: SearchParams = {
1478
+ query: this.fullQueryWithoutAlphaFilters,
1479
+ rows: 0,
1480
+ // Only fetch the firstTitle or firstCreator aggregation
1481
+ aggregations: { simpleParams: [filterAggregationKey] },
1482
+ // Fetch all 26 letter buckets
1483
+ aggregationsSize: 26,
1484
+ };
1485
+
1486
+ const searchResponse = await this.searchService?.search(
1487
+ params,
1488
+ this.searchType
1489
+ );
1490
+
1491
+ return (searchResponse?.success?.response?.aggregations?.[
1492
+ filterAggregationKey
1493
+ ]?.buckets ?? []) as Bucket[];
1494
+ }
1495
+
1496
+ /** Fetches and caches the prefix filter counts for the given filter type. */
1497
+ private async updatePrefixFilterCounts(
1498
+ filterType: PrefixFilterType
1499
+ ): Promise<void> {
1500
+ const buckets = await this.fetchPrefixFilterBuckets(filterType);
1501
+ // Unpack the aggregation buckets into a simple map like { 'A': 50, 'B': 25, ... }
1502
+ this.prefixFilterCountMap = { ...this.prefixFilterCountMap }; // Clone the object to trigger an update
1503
+ this.prefixFilterCountMap[filterType] = buckets.reduce(
1504
+ (acc: Record<string, number>, bucket: Bucket) => {
1505
+ acc[(bucket.key as string).toUpperCase()] = bucket.doc_count;
1506
+ return acc;
1507
+ },
1508
+ {}
1509
+ );
1510
+ }
1511
+
1512
+ /**
1513
+ * Fetches and caches the prefix filter counts for the current sort type,
1514
+ * provided it is one that permits prefix filtering. (If not, this does nothing).
1515
+ */
1516
+ private async updatePrefixFiltersForCurrentSort(): Promise<void> {
1517
+ if (['title', 'creator'].includes(this.selectedSort)) {
1518
+ const filterType = this.selectedSort as PrefixFilterType;
1519
+ if (!this.prefixFilterCountMap[filterType]) {
1520
+ this.updatePrefixFilterCounts(filterType);
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Clears the cached letter counts for both title and creator, and
1527
+ * fetches a new set of counts for whichever of them is the currently
1528
+ * selected sort option (which may be neither).
1529
+ *
1530
+ * Call this whenever the counts are invalidated (e.g., by a query change).
1531
+ */
1532
+ private refreshLetterCounts(): void {
1533
+ this.prefixFilterCountMap = {};
1534
+ this.updatePrefixFiltersForCurrentSort();
1535
+ }
1536
+
1323
1537
  /*
1324
1538
  * Convert etree titles
1325
1539
  * "[Creator] Live at [Place] on [Date]" => "[Date]: [Place]"
@@ -1385,8 +1599,10 @@ export class CollectionBrowser
1385
1599
  * increase the number of pages to render and start fetching data for the new page
1386
1600
  */
1387
1601
  private scrollThresholdReached() {
1388
- this.pagesToRender += 1;
1389
- this.fetchPage(this.pagesToRender);
1602
+ if (!this.endOfDataReached) {
1603
+ this.pagesToRender += 1;
1604
+ this.fetchPage(this.pagesToRender);
1605
+ }
1390
1606
  }
1391
1607
 
1392
1608
  static styles = css`
@@ -17,6 +17,7 @@ import {
17
17
  SearchParams,
18
18
  SearchType,
19
19
  AggregationSortType,
20
+ FilterMap,
20
21
  } from '@internetarchive/search-service';
21
22
  import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
22
23
  import type { ModalManagerInterface } from '@internetarchive/modal-manager';
@@ -44,7 +45,9 @@ export class MoreFacetsContent extends LitElement {
44
45
 
45
46
  @property({ type: String }) facetAggregationKey?: FacetOption;
46
47
 
47
- @property({ type: String }) fullQuery?: string;
48
+ @property({ type: String }) query?: string;
49
+
50
+ @property({ type: Object }) filterMap?: FilterMap;
48
51
 
49
52
  @property({ type: Object }) modalManager?: ModalManagerInterface;
50
53
 
@@ -127,7 +130,8 @@ export class MoreFacetsContent extends LitElement {
127
130
  const aggregationsSize = 65535; // todo - do we want to have all the records at once?
128
131
 
129
132
  const params: SearchParams = {
130
- query: this.fullQuery as string,
133
+ query: this.query as string,
134
+ filters: this.filterMap,
131
135
  aggregations,
132
136
  aggregationsSize,
133
137
  rows: 0, // todo - do we want server-side pagination with offset/page/limit flag?
@@ -12,6 +12,7 @@ import { map } from 'lit/directives/map.js';
12
12
  import type {
13
13
  Aggregation,
14
14
  Bucket,
15
+ FilterMap,
15
16
  SearchServiceInterface,
16
17
  SearchType,
17
18
  } from '@internetarchive/search-service';
@@ -72,7 +73,9 @@ export class CollectionFacets extends LitElement {
72
73
 
73
74
  @property({ type: Boolean }) showHistogramDatePicker = false;
74
75
 
75
- @property({ type: String }) fullQuery?: string;
76
+ @property({ type: String }) query?: string;
77
+
78
+ @property({ type: Object }) filterMap?: FilterMap;
76
79
 
77
80
  @property({ type: Object, attribute: false })
78
81
  modalManager?: ModalManagerInterface;
@@ -491,7 +494,8 @@ export class CollectionFacets extends LitElement {
491
494
  .analyticsHandler=${this.analyticsHandler}
492
495
  .facetKey=${facetGroup.key}
493
496
  .facetAggregationKey=${facetAggrKey}
494
- .fullQuery=${this.fullQuery}
497
+ .query=${this.query}
498
+ .filterMap=${this.filterMap}
495
499
  .modalManager=${this.modalManager}
496
500
  .searchService=${this.searchService}
497
501
  .searchType=${this.searchType}
package/src/models.ts CHANGED
@@ -119,6 +119,21 @@ export const MetadataFieldToSortField: {
119
119
  creatorSorter: SortField.creator,
120
120
  };
121
121
 
122
+ /** A union of the fields that permit prefix filtering (e.g., alphabetical filtering) */
123
+ export type PrefixFilterType = 'title' | 'creator';
124
+
125
+ /** A map from prefixes (e.g., initial letters) to the number of items matching that prefix */
126
+ export type PrefixFilterCounts = Record<string, number>;
127
+
128
+ /**
129
+ * A map from prefix filter types to the corresponding aggregation keys
130
+ * that are needed to fetch the filter counts from the backend.
131
+ */
132
+ export const prefixFilterAggregationKeys: Record<PrefixFilterType, string> = {
133
+ title: 'firstTitle',
134
+ creator: 'firstCreator',
135
+ };
136
+
122
137
  export type FacetOption =
123
138
  | 'subject'
124
139
  | 'lending'
@@ -25,7 +25,6 @@ export interface RestorationState {
25
25
  selectedFacets: SelectedFacets;
26
26
  baseQuery?: string;
27
27
  currentPage?: number;
28
- dateRangeQueryClause?: string;
29
28
  titleQuery?: string;
30
29
  creatorQuery?: string;
31
30
  minSelectedDate?: string;
@@ -142,8 +141,11 @@ export class RestorationStateHandler
142
141
  }
143
142
  }
144
143
 
145
- if (state.dateRangeQueryClause) {
146
- searchParams.append('and[]', state.dateRangeQueryClause);
144
+ if (state.minSelectedDate && state.maxSelectedDate) {
145
+ searchParams.append(
146
+ 'and[]',
147
+ `year:[${state.minSelectedDate} TO ${state.maxSelectedDate}]`
148
+ );
147
149
  }
148
150
  if (state.titleQuery) {
149
151
  searchParams.append('and[]', state.titleQuery);
@@ -159,7 +161,8 @@ export class RestorationStateHandler
159
161
  page: state.currentPage,
160
162
  and: state.selectedFacets,
161
163
  not: state.selectedFacets,
162
- dateRange: state.dateRangeQueryClause,
164
+ minDate: state.minSelectedDate,
165
+ maxDate: state.maxSelectedDate,
163
166
  },
164
167
  '',
165
168
  url
@@ -244,7 +247,6 @@ export class RestorationStateHandler
244
247
  0,
245
248
  maxDate.length - 1
246
249
  );
247
- restorationState.dateRangeQueryClause = `year:${value}`;
248
250
  } else {
249
251
  this.setSelectedFacetState(
250
252
  restorationState.selectedFacets,