this.dataSource.checkAllTiles()}
+ @unselectAll=${() => this.dataSource.uncheckAllTiles()}
@cancel=${() => {
this.isManageView = false;
- this.uncheckAllTiles();
+ this.dataSource.uncheckAllTiles();
}}
>
`;
@@ -659,7 +670,7 @@ export class CollectionBrowser
this.dispatchEvent(
new CustomEvent<{ items: ManageableItem[] }>('itemRemovalRequested', {
detail: {
- items: this.checkedTileModels.map(model => ({
+ items: this.dataSource.checkedTileModels.map(model => ({
...model,
date: formatDate(model.datePublished, 'long'),
})),
@@ -673,113 +684,7 @@ export class CollectionBrowser
* of the data source to account for any new gaps in the data.
*/
removeCheckedTiles(): void {
- // To make sure our data source remains page-aligned, we will offset our data source by
- // the number of removed tiles, so that we can just add the offset when the infinite
- // scroller queries for cell contents.
- // This only matters while we're still viewing the same set of results. If the user changes
- // their query/filters/sort, then the data source is overwritten and the offset cleared.
- const { checkedTileModels, uncheckedTileModels } = this;
- const numChecked = checkedTileModels.length;
- if (numChecked === 0) return;
- this.tileModelOffset += numChecked;
-
- const newDataSource: typeof this.dataSource = {};
-
- // Which page the remaining tile models start on, post-offset
- let offsetPageNumber = Math.floor(this.tileModelOffset / this.pageSize) + 1;
- let indexOnPage = this.tileModelOffset % this.pageSize;
-
- // Fill the pages up to that point with empty models
- for (let page = 1; page <= offsetPageNumber; page += 1) {
- const remainingHidden = this.tileModelOffset - this.pageSize * (page - 1);
- const offsetCellsOnPage = Math.min(this.pageSize, remainingHidden);
- newDataSource[page] = Array(offsetCellsOnPage).fill(undefined);
- }
-
- // Shift all the remaining tiles into their new positions in the data source
- for (const model of uncheckedTileModels) {
- if (!newDataSource[offsetPageNumber])
- newDataSource[offsetPageNumber] = [];
- newDataSource[offsetPageNumber].push(model);
- indexOnPage += 1;
- if (indexOnPage >= this.pageSize) {
- offsetPageNumber += 1;
- indexOnPage = 0;
- }
- }
-
- // Swap in the new data source and update the infinite scroller
- this.dataSource = newDataSource;
- if (this.totalResults) this.totalResults -= numChecked;
- if (this.infiniteScroller) {
- this.infiniteScroller.itemCount -= numChecked;
- this.infiniteScroller.refreshAllVisibleCells();
- }
- }
-
- /**
- * Checks every tile's management checkbox
- */
- checkAllTiles(): void {
- this.mapDataSource(model => ({ ...model, checked: true }));
- }
-
- /**
- * Unchecks every tile's management checkbox
- */
- uncheckAllTiles(): void {
- this.mapDataSource(model => ({ ...model, checked: false }));
- }
-
- /**
- * Applies the given map function to all of the tile models in every page of the data
- * source. This method updates the data source object in immutable fashion.
- *
- * @param mapFn A callback function to apply on each tile model, as with Array.map
- */
- private mapDataSource(
- mapFn: (model: TileModel, index: number, array: TileModel[]) => TileModel
- ): void {
- this.dataSource = Object.fromEntries(
- Object.entries(this.dataSource).map(([page, tileModels]) => [
- page,
- tileModels.map((model, index, array) =>
- model ? mapFn(model, index, array) : model
- ),
- ])
- );
- this.infiniteScroller?.refreshAllVisibleCells();
- }
-
- /**
- * An array of all the tile models whose management checkboxes are checked
- */
- get checkedTileModels(): TileModel[] {
- return this.getFilteredTileModels(model => model.checked);
- }
-
- /**
- * An array of all the tile models whose management checkboxes are unchecked
- */
- get uncheckedTileModels(): TileModel[] {
- return this.getFilteredTileModels(model => !model.checked);
- }
-
- /**
- * Returns a flattened, filtered array of all the tile models in the data source
- * for which the given predicate returns a truthy value.
- *
- * @param predicate A callback function to apply on each tile model, as with Array.filter
- * @returns A filtered array of tile models satisfying the predicate
- */
- private getFilteredTileModels(
- predicate: (model: TileModel, index: number, array: TileModel[]) => unknown
- ): TileModel[] {
- return Object.values(this.dataSource)
- .flat()
- .filter((model, index, array) =>
- model ? predicate(model, index, array) : false
- );
+ this.dataSource.removeCheckedTiles();
}
private userChangedSort(
@@ -811,10 +716,14 @@ export class CollectionBrowser
}
private selectedSortChanged(): void {
+ // Lazy-load the alphabet counts for title/creator sort bar as needed
+ this.updatePrefixFiltersForCurrentSort();
+ }
+
+ get sortParam(): SortParam | null {
const sortOption = SORT_OPTIONS[this.selectedSort];
- if (!sortOption.handledBySearchService) {
- this.sortParam = null;
- return;
+ if (!sortOption?.handledBySearchService) {
+ return null;
}
// If the sort option specified in the URL is unrecognized, we just use it as-is
@@ -826,11 +735,8 @@ export class CollectionBrowser
// (i.e., it was unrecognized and had no directional flag)
if (!this.sortDirection) this.sortDirection = 'asc';
- if (!sortField) return;
- this.sortParam = { field: sortField, direction: this.sortDirection };
-
- // Lazy-load the alphabet counts for title/creator sort bar as needed
- this.updatePrefixFiltersForCurrentSort();
+ if (!sortField) return null;
+ return { field: sortField, direction: this.sortDirection };
}
private displayModeChanged(
@@ -970,25 +876,26 @@ export class CollectionBrowser
@facetsChanged=${this.facetsChanged}
@histogramDateRangeUpdated=${this.histogramDateRangeUpdated}
.collectionPagePath=${this.collectionPagePath}
- .parentCollections=${this.parentCollections}
+ .parentCollections=${this.dataSource.parentCollections ?? []}
.withinCollection=${this.withinCollection}
.searchService=${this.searchService}
.featureFeedbackService=${this.featureFeedbackService}
.recaptchaManager=${this.recaptchaManager}
.resizeObserver=${this.resizeObserver}
.searchType=${this.searchType}
- .aggregations=${this.aggregations}
- .fullYearsHistogramAggregation=${this.fullYearsHistogramAggregation}
+ .aggregations=${this.dataSource.aggregations}
+ .fullYearsHistogramAggregation=${this.dataSource
+ .yearHistogramAggregation}
.minSelectedDate=${this.minSelectedDate}
.maxSelectedDate=${this.maxSelectedDate}
.selectedFacets=${this.selectedFacets}
.baseNavigationUrl=${this.baseNavigationUrl}
- .collectionNameCache=${this.collectionNameCache}
+ .collectionTitles=${this.dataSource.collectionTitles}
.showHistogramDatePicker=${this.showHistogramDatePicker}
.allowExpandingDatePicker=${!this.mobileView}
.contentWidth=${this.contentWidth}
.query=${this.baseQuery}
- .filterMap=${this.filterMap}
+ .filterMap=${this.dataSource.filterMap}
.isManageView=${this.isManageView}
.modalManager=${this.modalManager}
?collapsableFacets=${this.mobileView}
@@ -1095,7 +1002,15 @@ export class CollectionBrowser
this.restoreState();
}
+ willUpdate() {
+ this.setPlaceholderType();
+ console.warn('willUpdate *** ', this.placeholderType);
+ }
+
updated(changed: PropertyValues) {
+ console.warn('updated *** ', {
+ changed,
+ });
if (changed.has('placeholderType') && this.placeholderType === null) {
if (!this.leftColIntersectionObserver) {
this.setupLeftColumnScrollListeners();
@@ -1110,7 +1025,8 @@ export class CollectionBrowser
changed.has('displayMode') ||
changed.has('baseNavigationUrl') ||
changed.has('baseImageUrl') ||
- changed.has('loggedIn')
+ changed.has('loggedIn') ||
+ (changed.has('baseQuery') && !this.baseQuery)
) {
this.infiniteScroller?.reload();
}
@@ -1194,10 +1110,13 @@ export class CollectionBrowser
changed.has('selectedCreatorFilter') ||
changed.has('minSelectedDate') ||
changed.has('maxSelectedDate') ||
- changed.has('sortParam') ||
+ changed.has('selectedSort') ||
+ changed.has('sortDirection') ||
changed.has('selectedFacets') ||
changed.has('searchService') ||
- changed.has('withinCollection')
+ changed.has('withinCollection') ||
+ changed.has('withinProfile') ||
+ changed.has('profileElement')
) {
this.handleQueryChange();
}
@@ -1352,7 +1271,28 @@ export class CollectionBrowser
);
}
- private emitEmptyResults() {
+ private emitQueryStateChanged() {
+ this.dispatchEvent(
+ new CustomEvent
('queryStateChanged', {
+ detail: {
+ baseQuery: this.baseQuery,
+ withinCollection: this.withinCollection,
+ withinProfile: this.withinProfile,
+ profileElement: this.profileElement,
+ searchType: this.searchType,
+ selectedFacets: this.selectedFacets,
+ minSelectedDate: this.minSelectedDate,
+ maxSelectedDate: this.maxSelectedDate,
+ selectedSort: this.selectedSort,
+ sortDirection: this.sortDirection,
+ selectedTitleFilter: this.selectedTitleFilter,
+ selectedCreatorFilter: this.selectedCreatorFilter,
+ },
+ })
+ );
+ }
+
+ emitEmptyResults() {
this.dispatchEvent(new Event('emptyResults'));
}
@@ -1435,25 +1375,34 @@ export class CollectionBrowser
}
private async handleQueryChange() {
+ console.log('CB: handling query change', {
+ previousQueryKey: this.previousQueryKey,
+ ds_pageFetchQueryKey: this.dataSource.pageFetchQueryKey,
+ ds_canPerformSearch: this.dataSource.canPerformSearch,
+ });
// only reset if the query has actually changed
- if (!this.searchService || this.pageFetchQueryKey === this.previousQueryKey)
+ if (
+ !this.searchService ||
+ this.dataSource.pageFetchQueryKey === this.previousQueryKey
+ )
return;
// If the new state prevents us from updating the search results, don't reset
if (
- !this.canPerformSearch &&
+ !this.dataSource.canPerformSearch &&
!(this.clearResultsOnEmptyQuery && this.baseQuery === '')
)
return;
- this.previousQueryKey = this.pageFetchQueryKey;
+ console.log('CB will reset', {
+ baseQuery: this.baseQuery,
+ selectedFacets: JSON.stringify(this.selectedFacets),
+ });
+ this.previousQueryKey = this.dataSource.pageFetchQueryKey;
+ this.emitQueryStateChanged();
- this.dataSource = {};
this.tileModelOffset = 0;
this.totalResults = undefined;
- this.aggregations = undefined;
- this.fullYearsHistogramAggregation = undefined;
- this.pageFetchesInProgress = {};
this.endOfDataReached = false;
this.pagesToRender =
this.initialPageNumber === 1
@@ -1491,10 +1440,7 @@ export class CollectionBrowser
});
// Fire the initial page and facets requests
- await Promise.all([
- this.doInitialPageFetch(),
- this.suppressFacets ? null : this.fetchFacets(),
- ]);
+ await this.dataSource.handleQueryChange();
// Resolve the `initialSearchComplete` promise for this search
this._initialSearchCompleteResolver(true);
@@ -1524,7 +1470,7 @@ export class CollectionBrowser
this.selectedTitleFilter = restorationState.selectedTitleFilter ?? null;
this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
this.selectedFacets = restorationState.selectedFacets;
- this.baseQuery = restorationState.baseQuery;
+ if (!this.suppressURLQuery) this.baseQuery = restorationState.baseQuery;
this.currentPage = restorationState.currentPage ?? 1;
this.minSelectedDate = restorationState.minSelectedDate;
this.maxSelectedDate = restorationState.maxSelectedDate;
@@ -1540,7 +1486,7 @@ export class CollectionBrowser
selectedSort: this.selectedSort,
sortDirection: this.sortDirection ?? undefined,
selectedFacets: this.selectedFacets ?? getDefaultSelectedFacets(),
- baseQuery: this.baseQuery,
+ baseQuery: this.suppressURLQuery ? undefined : this.baseQuery,
currentPage: this.currentPage,
titleQuery: this.titleQuery,
creatorQuery: this.creatorQuery,
@@ -1552,13 +1498,6 @@ export class CollectionBrowser
this.restorationStateHandler.persistState(restorationState);
}
- private async doInitialPageFetch(): Promise {
- this.searchResultsLoading = true;
- // Try to batch 2 initial page requests when possible
- await this.fetchPage(this.initialPageNumber, 2);
- this.searchResultsLoading = false;
- }
-
private emitSearchResultsLoadingChanged(): void {
this.dispatchEvent(
new CustomEvent<{ loading: boolean }>('searchResultsLoadingChanged', {
@@ -1569,210 +1508,6 @@ export class CollectionBrowser
);
}
- /**
- * Produces a compact unique ID for a search request that can help with debugging
- * on the backend by making related requests easier to trace through different services.
- * (e.g., tying the hits/aggregations requests for the same page back to a single hash).
- *
- * @param params The search service parameters for the request
- * @param kind The kind of request (hits-only, aggregations-only, or both)
- * @returns A Promise resolving to the uid to apply to the request
- */
- private async requestUID(
- params: SearchParams,
- kind: RequestKind
- ): Promise {
- const paramsToHash = JSON.stringify({
- pageType: params.pageType,
- pageTarget: params.pageTarget,
- query: params.query,
- fields: params.fields,
- filters: params.filters,
- sort: params.sort,
- searchType: this.searchType,
- });
-
- const fullQueryHash = (await sha1(paramsToHash)).slice(0, 20); // First 80 bits of SHA-1 are plenty for this
- const sessionId = (await this.getSessionId()).slice(0, 20); // Likewise
- const page = params.page ?? 0;
- const kindPrefix = kind.charAt(0); // f = full, h = hits, a = aggregations
- const currentTime = Date.now();
-
- return `R:${fullQueryHash}-S:${sessionId}-P:${page}-K:${kindPrefix}-T:${currentTime}`;
- }
-
- /**
- * Constructs a search service FilterMap object from the combination of
- * all the currently-applied filters. This includes any facets, letter
- * filters, and date range.
- */
- private get filterMap(): FilterMap {
- const builder = new FilterMapBuilder();
-
- // Add the date range, if applicable
- if (this.minSelectedDate) {
- builder.addFilter(
- 'year',
- this.minSelectedDate,
- FilterConstraint.GREATER_OR_EQUAL
- );
- }
- if (this.maxSelectedDate) {
- builder.addFilter(
- 'year',
- this.maxSelectedDate,
- FilterConstraint.LESS_OR_EQUAL
- );
- }
-
- // Add any selected facets
- if (this.selectedFacets) {
- for (const [facetName, facetValues] of Object.entries(
- this.selectedFacets
- )) {
- const { name, values } = this.prepareFacetForFetch(
- facetName,
- facetValues
- );
- for (const [value, bucket] of Object.entries(values)) {
- let constraint;
- if (bucket.state === 'selected') {
- constraint = FilterConstraint.INCLUDE;
- } else if (bucket.state === 'hidden') {
- constraint = FilterConstraint.EXCLUDE;
- }
-
- if (constraint) {
- builder.addFilter(name, value, constraint);
- }
- }
- }
- }
-
- // Add any letter filters
- if (this.selectedTitleFilter) {
- builder.addFilter(
- 'firstTitle',
- this.selectedTitleFilter,
- FilterConstraint.INCLUDE
- );
- }
- if (this.selectedCreatorFilter) {
- builder.addFilter(
- 'firstCreator',
- this.selectedCreatorFilter,
- FilterConstraint.INCLUDE
- );
- }
-
- const filterMap = builder.build();
- return filterMap;
- }
-
- /** The full query, including year facets and date range clauses */
- private get fullQuery(): string | undefined {
- let fullQuery = this.baseQuery?.trim() ?? '';
-
- const { facetQuery, dateRangeQueryClause, sortFilterQueries } = this;
-
- if (facetQuery) {
- fullQuery += ` AND ${facetQuery}`;
- }
- if (dateRangeQueryClause) {
- fullQuery += ` AND ${dateRangeQueryClause}`;
- }
- if (sortFilterQueries) {
- fullQuery += ` AND ${sortFilterQueries}`;
- }
- return fullQuery.trim();
- }
-
- /**
- * Generates a query string for the given facets
- *
- * Example: `mediatype:("collection" OR "audio" OR -"etree") AND year:("2000" OR "2001")`
- */
- private get facetQuery(): string | undefined {
- if (!this.selectedFacets) return undefined;
- const facetClauses = [];
- for (const [facetName, facetValues] of Object.entries(
- this.selectedFacets
- )) {
- facetClauses.push(this.buildFacetClause(facetName, facetValues));
- }
- return this.joinFacetClauses(facetClauses)?.trim();
- }
-
- /**
- * Builds an OR-joined facet clause for the given facet name and values.
- *
- * E.g., for name `subject` and values
- * `{ foo: { state: 'selected' }, bar: { state: 'hidden' } }`
- * this will produce the clause
- * `subject:("foo" OR -"bar")`.
- *
- * @param facetName The facet type (e.g., 'collection')
- * @param facetValues The facet buckets, mapped by their keys
- */
- private buildFacetClause(
- facetName: string,
- facetValues: Record
- ): string {
- const { name: facetQueryName, values } = this.prepareFacetForFetch(
- facetName,
- facetValues
- );
- const facetEntries = Object.entries(values);
- if (facetEntries.length === 0) return '';
-
- const facetValuesArray: string[] = [];
- for (const [key, facetData] of facetEntries) {
- const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
- facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
- }
-
- const valueQuery = facetValuesArray.join(` OR `);
- return `${facetQueryName}:(${valueQuery})`;
- }
-
- /**
- * Handles some special pre-request normalization steps for certain facet types
- * that require them.
- *
- * @param facetName The name of the facet type (e.g., 'language')
- * @param facetValues An array of values for that facet type
- */
- private prepareFacetForFetch(
- facetName: string,
- facetValues: Record
- ): { name: string; values: Record } {
- // eslint-disable-next-line prefer-const
- let [normalizedName, normalizedValues] = [facetName, facetValues];
-
- // The full "search engine" name of the lending field is "lending___status"
- if (facetName === 'lending') {
- normalizedName = 'lending___status';
- }
-
- return {
- name: normalizedName,
- values: normalizedValues,
- };
- }
-
- /**
- * Takes an array of facet clauses, and combines them into a
- * full AND-joined facet query string. Empty clauses are ignored.
- */
- private joinFacetClauses(facetClauses: string[]): string | undefined {
- const nonEmptyFacetClauses = facetClauses.filter(
- clause => clause.length > 0
- );
- return nonEmptyFacetClauses.length > 0
- ? `(${nonEmptyFacetClauses.join(' AND ')})`
- : undefined;
- }
-
facetsChanged(e: CustomEvent) {
this.selectedFacets = e.detail;
}
@@ -1800,76 +1535,6 @@ export class CollectionBrowser
});
}
- private async fetchFacets() {
- const trimmedQuery = this.baseQuery?.trim();
- if (!this.canPerformSearch) return;
-
- const { facetFetchQueryKey } = this;
-
- const sortParams = this.sortParam ? [this.sortParam] : [];
- const params: SearchParams = {
- ...this.collectionParams,
- query: trimmedQuery || '',
- rows: 0,
- filters: this.filterMap,
- // Fetch a few extra buckets beyond the 6 we show, in case some get suppressed
- aggregationsSize: 10,
- // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
- // The default aggregations for the search_results page type should be what we need here.
- };
- params.uid = await this.requestUID(
- { ...params, sort: sortParams },
- 'aggregations'
- );
-
- this.facetsLoading = true;
- const searchResponse = await this.searchService?.search(
- params,
- this.searchType
- );
- const success = searchResponse?.success;
-
- // This is checking to see if the query has changed since the data was fetched.
- // If so, we just want to discard this set of aggregations because they are
- // likely no longer valid for the newer query.
- const queryChangedSinceFetch =
- facetFetchQueryKey !== this.facetFetchQueryKey;
- if (queryChangedSinceFetch) return;
-
- if (!success) {
- const errorMsg = searchResponse?.error?.message;
- const detailMsg = searchResponse?.error?.details?.message;
-
- if (!errorMsg && !detailMsg) {
- // @ts-ignore: Property 'Sentry' does not exist on type 'Window & typeof globalThis'
- window?.Sentry?.captureMessage?.(
- 'Missing or malformed facet response from backend',
- 'error'
- );
- }
-
- return;
- }
-
- const { aggregations, collectionTitles } = success.response;
- this.aggregations = aggregations;
-
- if (collectionTitles) {
- this.collectionNameCache?.addKnownTitles(collectionTitles);
- } else if (this.aggregations?.collection) {
- this.collectionNameCache?.preloadIdentifiers(
- (this.aggregations.collection.buckets as Bucket[]).map(bucket =>
- bucket.key?.toString()
- )
- );
- }
-
- this.fullYearsHistogramAggregation =
- success?.response?.aggregations?.year_histogram;
-
- this.facetsLoading = false;
- }
-
private scrollToPage(pageNumber: number): Promise {
return new Promise(resolve => {
const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
@@ -1900,211 +1565,14 @@ export class CollectionBrowser
}
/**
- * Whether a search may be performed in the current state of the component.
- * This is only true if the search service is defined, and either
- * (a) a non-empty query is set, or
- * (b) we are on a collection page in metadata search mode.
- */
- private get canPerformSearch(): boolean {
- if (!this.searchService) return false;
-
- const trimmedQuery = this.baseQuery?.trim();
- const hasNonEmptyQuery = !!trimmedQuery;
- const isCollectionSearch = !!this.withinCollection;
- const isMetadataSearch = this.searchType === SearchType.METADATA;
-
- // Metadata searches within a collection are allowed to have no query.
- // Otherwise, a non-empty query must be set.
- return hasNonEmptyQuery || (isCollectionSearch && isMetadataSearch);
- }
-
- /**
- * Additional params to pass to the search service if targeting a collection page,
- * or null otherwise.
+ * Sets the total number of tiles displayed in the infinite scroller.
*/
- private get collectionParams(): {
- pageType: string;
- pageTarget: string;
- } | null {
- return this.withinCollection
- ? { pageType: 'collection_details', pageTarget: this.withinCollection }
- : null;
- }
-
- /**
- * The query key is a string that uniquely identifies the current search.
- * It consists of:
- * - The current base query
- * - The current collection
- * - The current search type
- * - Any currently-applied facets
- * - Any currently-applied date range
- * - Any currently-applied prefix filters
- * - The current sort options
- *
- * This lets us keep track of queries so we don't persist data that's
- * no longer relevant.
- */
- private get pageFetchQueryKey(): string {
- const sortField = this.sortParam?.field ?? 'none';
- const sortDirection = this.sortParam?.direction ?? 'none';
- return `${this.fullQuery}-${this.withinCollection}-${this.searchType}-${sortField}-${sortDirection}`;
- }
-
- /**
- * Similar to `pageFetchQueryKey` above, but excludes sort fields since they
- * are not relevant in determining aggregation queries.
- */
- private get facetFetchQueryKey(): string {
- return `${this.fullQuery}-${this.withinCollection}-${this.searchType}`;
- }
-
- // this maps the query to the pages being fetched for that query
- private pageFetchesInProgress: Record> = {};
-
- /**
- * Fetches one or more pages of results and updates the data source.
- *
- * @param pageNumber The page number to fetch
- * @param numInitialPages If this is an initial page fetch (`pageNumber = 1`),
- * specifies how many pages to batch together in one request. Ignored
- * if `pageNumber != 1`, defaulting to a single page.
- */
- async fetchPage(pageNumber: number, numInitialPages = 1) {
- const trimmedQuery = this.baseQuery?.trim();
- if (!this.canPerformSearch) return;
-
- // if we already have data, don't fetch again
- if (this.dataSource[pageNumber]) return;
-
- if (this.endOfDataReached) return;
-
- // Batch multiple initial page requests together if needed (e.g., can request
- // pages 1 and 2 together in a single request).
- const numPages = pageNumber === 1 ? numInitialPages : 1;
- const numRows = this.pageSize * numPages;
-
- // if a fetch is already in progress for this query and page, don't fetch again
- const { pageFetchQueryKey } = this;
- const pageFetches =
- this.pageFetchesInProgress[pageFetchQueryKey] ?? new Set();
- if (pageFetches.has(pageNumber)) return;
- for (let i = 0; i < numPages; i += 1) {
- pageFetches.add(pageNumber + i);
- }
- this.pageFetchesInProgress[pageFetchQueryKey] = pageFetches;
-
- const sortParams = this.sortParam ? [this.sortParam] : [];
- const params: SearchParams = {
- ...this.collectionParams,
- query: trimmedQuery || '',
- page: pageNumber,
- rows: numRows,
- sort: sortParams,
- filters: this.filterMap,
- aggregations: { omit: true },
- };
- params.uid = await this.requestUID(params, 'hits');
-
- const searchResponse = await this.searchService?.search(
- params,
- this.searchType
- );
- const success = searchResponse?.success;
-
- // This is checking to see if the query has changed since the data was fetched.
- // If so, we just want to discard the data since there should be a new query
- // right behind it.
- const queryChangedSinceFetch = pageFetchQueryKey !== this.pageFetchQueryKey;
- if (queryChangedSinceFetch) return;
-
- if (!success) {
- const errorMsg = searchResponse?.error?.message;
- const detailMsg = searchResponse?.error?.details?.message;
-
- this.queryErrorMessage = `${errorMsg ?? ''}${
- detailMsg ? `; ${detailMsg}` : ''
- }`;
-
- if (!this.queryErrorMessage) {
- this.queryErrorMessage = 'Missing or malformed response from backend';
- // @ts-ignore: Property 'Sentry' does not exist on type 'Window & typeof globalThis'
- window?.Sentry?.captureMessage?.(this.queryErrorMessage, 'error');
- }
-
- for (let i = 0; i < numPages; i += 1) {
- this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber + i);
- }
-
- this.searchResultsLoading = false;
- return;
- }
-
- this.totalResults = success.response.totalResults - this.tileModelOffset;
-
- // display event to offshoot when result count is zero.
- if (this.totalResults === 0) {
- this.emitEmptyResults();
- }
-
- if (this.withinCollection) {
- this.collectionInfo = success.response.collectionExtraInfo;
-
- // For collections, we want the UI to respect the default sort option
- // which can be specified in metadata, or otherwise assumed to be week:desc
- this.applyDefaultCollectionSort(this.collectionInfo);
-
- if (this.collectionInfo) {
- this.parentCollections = [].concat(
- this.collectionInfo.public_metadata?.collection ?? []
- );
- }
- }
-
- const { results, collectionTitles } = success.response;
- if (results && results.length > 0) {
- // Load any collection titles present on the response into the cache,
- // or queue up preload fetches for them if none were present.
- if (collectionTitles) {
- this.collectionNameCache?.addKnownTitles(collectionTitles);
- } else {
- this.preloadCollectionNames(results);
- }
-
- // Update the data source for each returned page
- for (let i = 0; i < numPages; i += 1) {
- const pageStartIndex = this.pageSize * i;
- this.updateDataSource(
- pageNumber + i,
- results.slice(pageStartIndex, pageStartIndex + this.pageSize)
- );
- }
- }
-
- // When we reach the end of the data, we can set the infinite scroller's
- // item count to the real total number of results (rather than the
- // temporary estimates based on pages rendered so far).
- const resultCountDiscrepancy = numRows - results.length;
- if (resultCountDiscrepancy > 0) {
- this.endOfDataReached = true;
- if (this.infiniteScroller) {
- this.infiniteScroller.itemCount = this.totalResults;
- }
- }
-
- for (let i = 0; i < numPages; i += 1) {
- this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber + i);
+ setTileCount(count: number): void {
+ if (this.infiniteScroller) {
+ this.infiniteScroller.itemCount = count;
}
}
- private preloadCollectionNames(results: SearchResult[]) {
- const collectionIds = results
- .map(result => result.collection?.values)
- .flat();
- const collectionIdsArray = Array.from(new Set(collectionIds)) as string[];
- this.collectionNameCache?.preloadIdentifiers(collectionIdsArray);
- }
-
/**
* Applies any default sort option for the current collection, by checking
* for one in the collection's metadata. If none is found, defaults to sorting
@@ -2112,7 +1580,7 @@ export class CollectionBrowser
* - Date Favorited for fav-* collections
* - Weekly views for all other collections
*/
- private applyDefaultCollectionSort(collectionInfo?: CollectionExtraInfo) {
+ applyDefaultCollectionSort(collectionInfo?: CollectionExtraInfo) {
if (this.baseQuery) {
// If there's a query set, then we default to relevance sorting regardless of
// the collection metadata-specified sort.
@@ -2162,7 +1630,7 @@ export class CollectionBrowser
* When the fetch completes, we need to reload the scroller if the cells for that
* page are visible, but if the page is not currenlty visible, we don't need to reload
*/
- private get currentVisiblePageNumbers(): number[] {
+ get currentVisiblePageNumbers(): number[] {
const visibleCells = this.infiniteScroller?.getVisibleCellIndices() ?? [];
const visiblePages = new Set();
visibleCells.forEach(cellIndex => {
@@ -2173,169 +1641,10 @@ export class CollectionBrowser
}
/**
- * Update the datasource from the fetch response
- *
- * @param pageNumber
- * @param results
+ * Refreshes all visible result cells in the infinite scroller.
*/
- private updateDataSource(pageNumber: number, results: SearchResult[]) {
- // copy our existing datasource so when we set it below, it gets set
- // instead of modifying the existing dataSource since object changes
- // don't trigger a re-render
- const datasource = { ...this.dataSource };
- const tiles: TileModel[] = [];
- results?.forEach(result => {
- if (!result.identifier) return;
-
- let loginRequired = false;
- let contentWarning = false;
- // Check if item and item in "modifying" collection, setting above flags
- if (
- result.collection?.values.length &&
- result.mediatype?.value !== 'collection'
- ) {
- for (const collection of result.collection?.values ?? []) {
- if (collection === 'loggedin') {
- loginRequired = true;
- if (contentWarning) break;
- }
- if (collection === 'no-preview') {
- contentWarning = true;
- if (loginRequired) break;
- }
- }
- }
-
- tiles.push({
- averageRating: result.avg_rating?.value,
- checked: false,
- collections: result.collection?.values ?? [],
- collectionFilesCount: result.collection_files_count?.value ?? 0,
- collectionSize: result.collection_size?.value ?? 0,
- commentCount: result.num_reviews?.value ?? 0,
- creator: result.creator?.value,
- creators: result.creator?.values ?? [],
- dateAdded: result.addeddate?.value,
- dateArchived: result.publicdate?.value,
- datePublished: result.date?.value,
- dateReviewed: result.reviewdate?.value,
- description: result.description?.values.join('\n'),
- favCount: result.num_favorites?.value ?? 0,
- href: this.collapseRepeatedQuotes(result.__href__?.value),
- identifier: result.identifier,
- issue: result.issue?.value,
- itemCount: result.item_count?.value ?? 0,
- mediatype: this.getMediatype(result),
- snippets: result.highlight?.values ?? [],
- source: result.source?.value,
- subjects: result.subject?.values ?? [],
- title: result.title?.value ?? '',
- volume: result.volume?.value,
- viewCount: result.downloads?.value ?? 0,
- weeklyViewCount: result.week?.value,
- loginRequired,
- contentWarning,
- });
- });
- datasource[pageNumber] = tiles;
- this.dataSource = datasource;
- const visiblePages = this.currentVisiblePageNumbers;
- const needsReload = visiblePages.includes(pageNumber);
- if (needsReload) {
- this.infiniteScroller?.refreshAllVisibleCells();
- }
- }
-
- private getMediatype(result: SearchResult) {
- /**
- * hit_type == 'favorited_search' is basically a new hit_type
- * - we are getting from PPS.
- * - which gives response for fav- collection
- * - having favorited items like account/collection/item etc..
- * - as user can also favorite a search result (a search page)
- * - so we need to have response (having fav- items and fav- search results)
- *
- * if backend hit_type == 'favorited_search'
- * - let's assume a "search" as new mediatype
- */
- if (result?.rawMetadata?.hit_type === 'favorited_search') {
- return 'search';
- }
-
- return result.mediatype?.value ?? 'data';
- }
-
- /**
- * Returns the input string, but removing one set of quotes from all instances of
- * ""clauses wrapped in two sets of quotes"". This assumes the quotes are already
- * URL-encoded.
- *
- * This should be a temporary measure to address the fact that the __href__ field
- * sometimes acquires extra quotation marks during query rewriting. Once there is a
- * full Lucene parser in place that handles quoted queries correctly, this can likely
- * be removed.
- */
- private collapseRepeatedQuotes(str?: string): string | undefined {
- return str?.replace(/%22%22(?!%22%22)(.+?)%22%22/g, '%22$1%22');
- }
-
- /** Fetches the aggregation buckets for the given prefix filter type. */
- private async fetchPrefixFilterBuckets(
- filterType: PrefixFilterType
- ): Promise {
- const trimmedQuery = this.baseQuery?.trim();
- if (!this.canPerformSearch) return [];
-
- const filterAggregationKey = prefixFilterAggregationKeys[filterType];
- const sortParams = this.sortParam ? [this.sortParam] : [];
-
- const params: SearchParams = {
- ...this.collectionParams,
- query: trimmedQuery || '',
- rows: 0,
- filters: this.filterMap,
- // Only fetch the firstTitle or firstCreator aggregation
- aggregations: { simpleParams: [filterAggregationKey] },
- // Fetch all 26 letter buckets
- aggregationsSize: 26,
- };
- params.uid = await this.requestUID(
- { ...params, sort: sortParams },
- 'aggregations'
- );
-
- const searchResponse = await this.searchService?.search(
- params,
- this.searchType
- );
-
- return (searchResponse?.success?.response?.aggregations?.[
- filterAggregationKey
- ]?.buckets ?? []) as Bucket[];
- }
-
- /** Fetches and caches the prefix filter counts for the given filter type. */
- private async updatePrefixFilterCounts(
- filterType: PrefixFilterType
- ): Promise {
- const { facetFetchQueryKey } = this;
- const buckets = await this.fetchPrefixFilterBuckets(filterType);
-
- // Don't update the filter counts for an outdated query (if it has been changed
- // since we sent the request)
- const queryChangedSinceFetch =
- facetFetchQueryKey !== this.facetFetchQueryKey;
- if (queryChangedSinceFetch) return;
-
- // Unpack the aggregation buckets into a simple map like { 'A': 50, 'B': 25, ... }
- this.prefixFilterCountMap = { ...this.prefixFilterCountMap }; // Clone the object to trigger an update
- this.prefixFilterCountMap[filterType] = buckets.reduce(
- (acc: Record, bucket: Bucket) => {
- acc[(bucket.key as string).toUpperCase()] = bucket.doc_count;
- return acc;
- },
- {}
- );
+ refreshVisibleResults(): void {
+ this.infiniteScroller?.refreshAllVisibleCells();
}
/**
@@ -2346,7 +1655,7 @@ export class CollectionBrowser
if (['title', 'creator'].includes(this.selectedSort)) {
const filterType = this.selectedSort as PrefixFilterType;
if (!this.prefixFilterCountMap[filterType]) {
- this.updatePrefixFilterCounts(filterType);
+ this.dataSource.updatePrefixFilterCounts(filterType);
}
}
}
@@ -2372,9 +1681,7 @@ export class CollectionBrowser
if (this.isManageView) {
// Checked/unchecked state change -- rerender to ensure it propagates
// this.mapDataSource(model => ({ ...model }));
- const cellIndex = Object.values(this.dataSource)
- .flat()
- .indexOf(event.detail);
+ const cellIndex = this.dataSource.indexOf(event.detail);
if (cellIndex >= 0)
this.infiniteScroller?.refreshCell(cellIndex - this.tileModelOffset);
}
@@ -2404,10 +1711,10 @@ export class CollectionBrowser
.model=${model}
.tileDisplayMode=${this.displayMode}
.resizeObserver=${this.resizeObserver}
- .collectionNameCache=${this.collectionNameCache}
+ .collectionTitles=${this.dataSource.collectionTitles}
.sortParam=${this.sortParam}
.defaultSortParam=${this.defaultSortParam}
- .creatorFilter=${this.selectedCreatorFilter}
+ .creatorFilter=${this.selectedCreatorFilter ?? undefined}
.mobileBreakpoint=${this.mobileBreakpoint}
.loggedIn=${this.loggedIn}
.isManageView=${this.isManageView}
@@ -2425,7 +1732,11 @@ export class CollectionBrowser
private scrollThresholdReached() {
if (!this.endOfDataReached) {
this.pagesToRender += 1;
- this.fetchPage(this.pagesToRender);
+ console.warn(
+ '****** FETCH PAGE scrollThresholdReached',
+ this.pagesToRender
+ );
+ this.dataSource.fetchPage(this.pagesToRender);
}
}
diff --git a/src/collection-facets.ts b/src/collection-facets.ts
index 1dcb8a84a..b936c6a9d 100644
--- a/src/collection-facets.ts
+++ b/src/collection-facets.ts
@@ -22,8 +22,6 @@ import type {
} from '@internetarchive/search-service';
import '@internetarchive/histogram-date-range';
import '@internetarchive/feature-feedback';
-import '@internetarchive/collection-name-cache';
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import {
ModalConfig,
ModalManagerInterface,
@@ -47,6 +45,7 @@ import {
suppressedCollections,
defaultFacetSort,
} from './models';
+import type { CollectionTitles } from './data-source/models';
import './collection-facets/more-facets-content';
import './collection-facets/facets-template';
import './collection-facets/facet-tombstone-row';
@@ -118,7 +117,7 @@ export class CollectionFacets extends LitElement {
analyticsHandler?: AnalyticsManagerInterface;
@property({ type: Object, attribute: false })
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
@state() openFacets: Record = {
subject: false,
@@ -188,11 +187,7 @@ export class CollectionFacets extends LitElement {
data-id=${collxn}
@click=${this.partOfCollectionClicked}
>
-
+ ${this.collectionTitles?.get(collxn) ?? collxn}
`;
})}
@@ -652,7 +647,7 @@ export class CollectionFacets extends LitElement {
.modalManager=${this.modalManager}
.searchService=${this.searchService}
.searchType=${this.searchType}
- .collectionNameCache=${this.collectionNameCache}
+ .collectionTitles=${this.collectionTitles}
.selectedFacets=${this.selectedFacets}
.sortedBy=${sortedBy}
@facetsChanged=${(e: CustomEvent) => {
@@ -694,7 +689,7 @@ export class CollectionFacets extends LitElement {
.facetGroup=${facetGroup}
.selectedFacets=${this.selectedFacets}
.renderOn=${'page'}
- .collectionNameCache=${this.collectionNameCache}
+ .collectionTitles=${this.collectionTitles}
@selectedFacetsChanged=${(e: CustomEvent) => {
const event = new CustomEvent('facetsChanged', {
detail: e.detail,
diff --git a/src/collection-facets/facet-row.ts b/src/collection-facets/facet-row.ts
index 78b53ca05..30edafc46 100644
--- a/src/collection-facets/facet-row.ts
+++ b/src/collection-facets/facet-row.ts
@@ -7,7 +7,6 @@ import {
nothing,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import eyeIcon from '../assets/img/icons/eye';
import eyeClosedIcon from '../assets/img/icons/eye-closed';
import type {
@@ -16,6 +15,7 @@ import type {
FacetEventDetails,
FacetState,
} from '../models';
+import type { CollectionTitles } from '../data-source/models';
@customElement('facet-row')
export class FacetRow extends LitElement {
@@ -31,7 +31,7 @@ export class FacetRow extends LitElement {
/** The collection name cache for converting collection identifiers to titles */
@property({ type: Object })
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
//
// COMPONENT LIFECYCLE METHODS
@@ -56,18 +56,13 @@ export class FacetRow extends LitElement {
const showOnlyCheckboxId = `${facetType}:${bucket.key}-show-only`;
const negativeCheckboxId = `${facetType}:${bucket.key}-negative`;
- // For collections, we need to asynchronously load the collection name
- // so we use the `async-collection-name` widget.
+ // For collections, we render the collection title as a link.
// For other facet types, we just have a static value to use.
const bucketTextDisplay =
facetType !== 'collection'
? html`${bucket.displayText ?? bucket.key}`
: html`
-
+ ${this.collectionTitles?.get(bucket.key) ?? bucket.key}
`;
const facetHidden = bucket.state === 'hidden';
diff --git a/src/collection-facets/facets-template.ts b/src/collection-facets/facets-template.ts
index f0f2138eb..f7e92d376 100644
--- a/src/collection-facets/facets-template.ts
+++ b/src/collection-facets/facets-template.ts
@@ -8,7 +8,6 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import {
FacetGroup,
FacetBucket,
@@ -16,6 +15,7 @@ import {
getDefaultSelectedFacets,
FacetEventDetails,
} from '../models';
+import type { CollectionTitles } from '../data-source/models';
import { FacetRow } from './facet-row';
@customElement('facets-template')
@@ -27,7 +27,7 @@ export class FacetsTemplate extends LitElement {
@property({ type: String }) renderOn?: string;
@property({ type: Object })
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
private facetClicked(e: CustomEvent) {
const { bucket, negative } = e.detail;
@@ -126,7 +126,7 @@ export class FacetsTemplate extends LitElement {
bucket => html``
)}
diff --git a/src/collection-facets/more-facets-content.ts b/src/collection-facets/more-facets-content.ts
index 4f8cbffb3..ec491fcea 100644
--- a/src/collection-facets/more-facets-content.ts
+++ b/src/collection-facets/more-facets-content.ts
@@ -13,13 +13,13 @@ import { customElement, property, state } from 'lit/decorators.js';
import {
Aggregation,
Bucket,
+ PageType,
SearchServiceInterface,
SearchParams,
SearchType,
AggregationSortType,
FilterMap,
} from '@internetarchive/search-service';
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import type { ModalManagerInterface } from '@internetarchive/modal-manager';
import type { AnalyticsManagerInterface } from '@internetarchive/analytics-manager';
import {
@@ -32,6 +32,7 @@ import {
valueFacetSort,
defaultFacetSort,
} from '../models';
+import type { CollectionTitles } from '../data-source/models';
import '@internetarchive/ia-activity-indicator/ia-activity-indicator';
import './more-facets-pagination';
import './facets-template';
@@ -61,7 +62,7 @@ export class MoreFacetsContent extends LitElement {
@property({ type: String }) withinCollection?: string;
@property({ type: Object })
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
@property({ type: Object }) selectedFacets?: SelectedFacets;
@@ -134,6 +135,12 @@ export class MoreFacetsContent extends LitElement {
*/
async updateSpecificFacets(): Promise {
const trimmedQuery = this.query?.trim();
+
+ console.log('updateSpecificFacets', {
+ trimmedQuery,
+ withinCollection: this.withinCollection,
+ });
+
if (!trimmedQuery && !this.withinCollection) return;
const aggregations = {
@@ -142,7 +149,10 @@ export class MoreFacetsContent extends LitElement {
const aggregationsSize = 65535; // todo - do we want to have all the records at once?
const collectionParams = this.withinCollection
- ? { pageType: 'collection_details', pageTarget: this.withinCollection }
+ ? {
+ pageType: 'collection_details' as PageType,
+ pageTarget: this.withinCollection,
+ }
: null;
const params: SearchParams = {
@@ -154,11 +164,31 @@ export class MoreFacetsContent extends LitElement {
rows: 0, // todo - do we want server-side pagination with offset/page/limit flag?
};
+ console.log('updateSpecificFacets', {
+ params,
+ searchType: this.searchType,
+ });
+ console.log(' *** firing facet call *** ');
+
const results = await this.searchService?.search(params, this.searchType);
this.aggregations = results?.success?.response.aggregations;
this.facetGroup = this.aggregationFacetGroups;
this.facetsLoading = false;
+
+ console.log(' *** Returned facet call *** ', {
+ results,
+ aggregations: this.aggregations,
+ facetGroup: this.facetGroup,
+ facetsLoading: this.facetsLoading,
+ });
+
+ const collectionTitles = results?.success?.response?.collectionTitles;
+ if (collectionTitles) {
+ for (const [id, title] of Object.entries(collectionTitles)) {
+ this.collectionTitles?.set(id, title);
+ }
+ }
}
private pageNumberClicked(e: CustomEvent<{ page: number }>) {
@@ -285,9 +315,6 @@ export class MoreFacetsContent extends LitElement {
!suppressedCollections[bucketKey] && !bucketKey?.startsWith('fav-')
);
});
-
- // asynchronously load the collection name
- this.preloadCollectionNames(castedBuckets);
}
// find length and pagination size for modal pagination
@@ -320,26 +347,13 @@ export class MoreFacetsContent extends LitElement {
return facetGroups;
}
- /**
- * for collections, we need to asynchronously load the collection name
- * so we use the `async-collection-name` widget and for the rest, we have a static value to use
- *
- * @param castedBuckets
- */
- private preloadCollectionNames(castedBuckets: any[]) {
- const collectionIds = castedBuckets?.map(option => option.key);
- const collectionIdsArray = Array.from(new Set(collectionIds)) as string[];
-
- this.collectionNameCache?.preloadIdentifiers(collectionIdsArray);
- }
-
private get getMoreFacetsTemplate(): TemplateResult {
return html`
{
this.selectedFacets = e.detail;
}}
diff --git a/src/data-source/collection-browser-data-source.ts b/src/data-source/collection-browser-data-source.ts
new file mode 100644
index 000000000..b89de1fa1
--- /dev/null
+++ b/src/data-source/collection-browser-data-source.ts
@@ -0,0 +1,1231 @@
+import type { ReactiveController, ReactiveControllerHost } from 'lit';
+import {
+ AccountExtraInfo,
+ Aggregation,
+ Bucket,
+ CollectionExtraInfo,
+ FilterConstraint,
+ FilterMap,
+ FilterMapBuilder,
+ PageElementMap,
+ SearchParams,
+ SearchResult,
+ SearchType,
+} from '@internetarchive/search-service';
+import type { MediaType } from '@internetarchive/field-parsers';
+import {
+ prefixFilterAggregationKeys,
+ type FacetBucket,
+ type PrefixFilterType,
+ type TileModel,
+ PrefixFilterCounts,
+} from '../models';
+import type {
+ CollectionBrowserSearchInterface,
+ CollectionTitles,
+ PageSpecifierParams,
+} from './models';
+import { sha1 } from '../utils/sha1';
+
+type RequestKind = 'full' | 'hits' | 'aggregations';
+
+export interface CollectionBrowserDataSourceInterface
+ extends ReactiveController {
+ /**
+ * How many tile models are present in this data source
+ */
+ readonly size: number;
+
+ /**
+ * Whether the host has a valid set of properties for performing a search.
+ * For instance, on the search page this requires a valid search service and a
+ * non-empty query, while collection pages allow searching with an empty query
+ * for MDS but not FTS.
+ */
+ readonly canPerformSearch: boolean;
+
+ /**
+ * A string key compactly representing the current full search state, which can
+ * be used to determine, e.g., when a new search is required or whether an arriving
+ * response is outdated.
+ */
+ readonly pageFetchQueryKey: string;
+
+ /**
+ * Similar to `pageFetchQueryKey`, but excluding properties that do not affect
+ * the validity of a set of facets (e.g., sort).
+ */
+ readonly facetFetchQueryKey: string;
+
+ /**
+ * An object representing any collection- or profile-specific properties to be passed along
+ * to the search service, specifying the exact page/tab to fetch results for.
+ */
+ readonly pageSpecifierParams: PageSpecifierParams | null;
+
+ /**
+ * A FilterMap object representing all filters applied to the current search,
+ * including any facets, letter filters, and date ranges.
+ */
+ readonly filterMap: FilterMap;
+
+ /**
+ * The full set of aggregations retrieved for the current search.
+ */
+ readonly aggregations?: Record;
+
+ /**
+ * The `year_histogram` aggregation retrieved for the current search.
+ */
+ readonly yearHistogramAggregation?: Aggregation;
+
+ /**
+ * A map from collection identifiers that appear on hits or aggregations for the
+ * current search, to their human-readable collection titles.
+ */
+ readonly collectionTitles: CollectionTitles;
+
+ /**
+ * The "extra info" package provided by the PPS for collection pages, including details
+ * used to populate the target collection header & About tab content.
+ */
+ readonly collectionExtraInfo?: CollectionExtraInfo;
+
+ /**
+ * The "extra info" package provided by the PPS for profile pages, including details
+ * used to populate the profile header.
+ */
+ readonly accountExtraInfo?: AccountExtraInfo;
+
+ /**
+ * The set of requested page elements for profile pages, if applicable. These represent
+ * any content specific to the current profile tab.
+ */
+ readonly pageElements?: PageElementMap;
+
+ /**
+ * An array of the current target collection's parent collections. Should include *all*
+ * ancestors in the collection hierarchy, not just the immediate parent.
+ */
+ readonly parentCollections?: string[];
+
+ /**
+ * An object storing result counts for the current search bucketed by letter prefix.
+ * Keys are the result field on which the prefixes are considered (e.g., title/creator)
+ * and values are a Record mapping letters to their counts.
+ */
+ readonly prefixFilterCountMap: Partial<
+ Record
+ >;
+
+ /**
+ * An array of all the tile models whose management checkboxes are checked
+ */
+ readonly checkedTileModels: TileModel[];
+
+ /**
+ * An array of all the tile models whose management checkboxes are unchecked
+ */
+ readonly uncheckedTileModels: TileModel[];
+
+ /**
+ * Adds the given page of tile models to the data source.
+ * If the given page number already exists, that page will be overwritten.
+ * @param pageNum Which page number to add (indexed starting from 1)
+ * @param pageTiles The array of tile models for the new page
+ */
+ addPage(pageNum: number, pageTiles: TileModel[]): void;
+
+ /**
+ * Returns the given page of tile models from the data source.
+ * @param pageNum Which page number to get (indexed starting from 1)
+ */
+ getPage(pageNum: number): TileModel[];
+
+ /**
+ * Returns the full set of paged tile models stored in this data source.
+ */
+ getAllPages(): Record;
+
+ /**
+ * Whether the data source contains any tiles for the given page number.
+ * @param pageNum Which page number to query (indexed starting from 1)
+ */
+ hasPage(pageNum: number): boolean;
+
+ /**
+ * Returns the single tile model appearing at the given index in the
+ * data source, with respect to the current page size. Returns `undefined` if
+ * the corresponding page is not present on the data source or if it does not
+ * contain a tile model at the corresponding index.
+ * @param index The 0-based index (within the full data source) of the tile to get
+ */
+ getTileModelAt(index: number): TileModel | undefined;
+
+ /**
+ * Returns the first numeric tile index corresponding to the given tile model object,
+ * or -1 if the given tile model is not present.
+ * @param tile The tile model to search for in the data source
+ */
+ indexOf(tile: TileModel): number;
+
+ /**
+ * Requests that the data source fire a backend request for the given page of results.
+ * @param pageNum Which page number to fetch results for
+ * @param numInitialPages How many pages should be batched together on an initial fetch
+ */
+ fetchPage(pageNum: number, numInitialPages?: number): Promise;
+
+ /**
+ * Requests that the data source update its prefix bucket result counts for the given
+ * type of prefix filter.
+ * @param filterType Which prefixable field to update the buckets for (e.g., title/creator)
+ */
+ updatePrefixFilterCounts(filterType: PrefixFilterType): Promise;
+
+ /**
+ * Changes the page size used by the data source, discarding any previously-fetched pages.
+ *
+ * **Note: this operation will reset any data stored in the data source!**
+ * @param pageSize
+ */
+ setPageSize(pageSize: number): void;
+
+ /**
+ * Resets the data source to its empty state, with no result pages, aggregations, etc.
+ */
+ reset(): void;
+
+ /**
+ * Notifies the data source that a query change has occurred, which may trigger a data
+ * reset & new fetches.
+ */
+ handleQueryChange(): void;
+
+ /**
+ * Applies the given map function to all of the tile models in every page of the data
+ * source.
+ * @param callback A callback function to apply on each tile model, as with Array.map
+ */
+ map(
+ callback: (model: TileModel, index: number, array: TileModel[]) => TileModel
+ ): void;
+
+ /**
+ * Checks every tile's management checkbox
+ */
+ checkAllTiles(): void;
+
+ /**
+ * Unchecks every tile's management checkbox
+ */
+ uncheckAllTiles(): void;
+
+ /**
+ * Removes all tile models that are currently checked & adjusts the paging
+ * of the data source to account for any new gaps in the data.
+ */
+ removeCheckedTiles(): void;
+}
+
+export class CollectionBrowserDataSource
+ implements CollectionBrowserDataSourceInterface
+{
+ private pages: Record = {};
+
+ private offset = 0;
+
+ private numTileModels = 0;
+
+ /**
+ * Maps the full query key to the pages being fetched for that query
+ */
+ private pageFetchesInProgress: Record> = {};
+
+ totalResults = 0;
+
+ endOfDataReached = false;
+
+ /**
+ * @inheritdoc
+ */
+ aggregations?: Record;
+
+ /**
+ * @inheritdoc
+ */
+ yearHistogramAggregation?: Aggregation;
+
+ /**
+ * @inheritdoc
+ */
+ collectionTitles = new Map();
+
+ /**
+ * @inheritdoc
+ */
+ collectionExtraInfo?: CollectionExtraInfo;
+
+ /**
+ * @inheritdoc
+ */
+ accountExtraInfo?: AccountExtraInfo;
+
+ /**
+ * @inheritdoc
+ */
+ pageElements?: PageElementMap;
+
+ /**
+ * @inheritdoc
+ */
+ parentCollections?: string[] = [];
+
+ /**
+ * @inheritdoc
+ */
+ prefixFilterCountMap: Partial> =
+ {};
+
+ constructor(
+ /** The host element to which this controller should attach listeners */
+ private readonly host: ReactiveControllerHost &
+ CollectionBrowserSearchInterface,
+ /** Default size of result pages */
+ private pageSize: number
+ ) {
+ this.host.addController(this as CollectionBrowserDataSourceInterface);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ get size(): number {
+ return this.numTileModels;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ addPage(pageNum: number, pageTiles: TileModel[]): void {
+ this.pages[pageNum] = pageTiles;
+ this.numTileModels += pageTiles.length;
+ this.host.requestUpdate();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getPage(pageNum: number): TileModel[] {
+ return this.pages[pageNum];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getAllPages(): Record {
+ return this.pages;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ hasPage(pageNum: number): boolean {
+ return !!this.pages[pageNum];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getTileModelAt(index: number): TileModel | undefined {
+ const pageNum = Math.floor(index / this.pageSize) + 1;
+ const indexOnPage = index % this.pageSize;
+ return this.pages[pageNum]?.[indexOnPage];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ indexOf(tile: TileModel): number {
+ return Object.values(this.pages).flat().indexOf(tile);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ setPageSize(pageSize: number): void {
+ this.reset();
+ this.pageSize = pageSize;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ reset(): void {
+ this.pages = {};
+ this.aggregations = {};
+ this.yearHistogramAggregation = undefined;
+ this.pageFetchesInProgress = {};
+ this.pageElements = undefined;
+ this.parentCollections = [];
+ this.prefixFilterCountMap = {};
+
+ this.offset = 0;
+ this.numTileModels = 0;
+ this.totalResults = 0;
+ this.endOfDataReached = false;
+
+ this.host.requestUpdate();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async handleQueryChange(): Promise {
+ this.reset();
+ await Promise.all([
+ this.doInitialPageFetch(),
+ this.host.suppressFacets ? null : this.fetchFacets(),
+ ]);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ map(
+ callback: (model: TileModel, index: number, array: TileModel[]) => TileModel
+ ): void {
+ this.pages = Object.fromEntries(
+ Object.entries(this.pages).map(([page, tileModels]) => [
+ page,
+ tileModels.map((model, index, array) =>
+ model ? callback(model, index, array) : model
+ ),
+ ])
+ );
+ this.host.requestUpdate();
+ this.host.refreshVisibleResults();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ checkAllTiles = (): void => {
+ this.map(model => ({ ...model, checked: true }));
+ };
+
+ /**
+ * @inheritdoc
+ */
+ uncheckAllTiles = (): void => {
+ this.map(model => ({ ...model, checked: false }));
+ };
+
+ /**
+ * @inheritdoc
+ */
+ removeCheckedTiles = (): void => {
+ // To make sure our data source remains page-aligned, we will offset our data source by
+ // the number of removed tiles, so that we can just add the offset when the infinite
+ // scroller queries for cell contents.
+ // This only matters while we're still viewing the same set of results. If the user changes
+ // their query/filters/sort, then the data source is overwritten and the offset cleared.
+ const { checkedTileModels, uncheckedTileModels } = this;
+ const numChecked = checkedTileModels.length;
+ if (numChecked === 0) return;
+ this.offset += numChecked;
+ const newPages: typeof this.pages = {};
+
+ // Which page the remaining tile models start on, post-offset
+ let offsetPageNumber = Math.floor(this.offset / this.pageSize) + 1;
+ let indexOnPage = this.offset % this.pageSize;
+
+ // Fill the pages up to that point with empty models
+ for (let page = 1; page <= offsetPageNumber; page += 1) {
+ const remainingHidden = this.offset - this.pageSize * (page - 1);
+ const offsetCellsOnPage = Math.min(this.pageSize, remainingHidden);
+ newPages[page] = Array(offsetCellsOnPage).fill(undefined);
+ }
+
+ // Shift all the remaining tiles into their new positions in the data source
+ for (const model of uncheckedTileModels) {
+ if (!newPages[offsetPageNumber]) newPages[offsetPageNumber] = [];
+ newPages[offsetPageNumber].push(model);
+ indexOnPage += 1;
+ if (indexOnPage >= this.pageSize) {
+ offsetPageNumber += 1;
+ indexOnPage = 0;
+ }
+ }
+
+ // Swap in the new pages
+ this.pages = newPages;
+ this.numTileModels -= numChecked;
+ this.host.requestUpdate();
+ };
+
+ /**
+ * @inheritdoc
+ */
+ get checkedTileModels(): TileModel[] {
+ return this.getFilteredTileModels(model => model.checked);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ get uncheckedTileModels(): TileModel[] {
+ return this.getFilteredTileModels(model => !model.checked);
+ }
+
+ /**
+ * Returns a flattened, filtered array of all the tile models in the data source
+ * for which the given predicate returns a truthy value.
+ *
+ * @param predicate A callback function to apply on each tile model, as with Array.filter
+ * @returns A filtered array of tile models satisfying the predicate
+ */
+ private getFilteredTileModels(
+ predicate: (model: TileModel, index: number, array: TileModel[]) => unknown
+ ): TileModel[] {
+ return Object.values(this.pages)
+ .flat()
+ .filter((model, index, array) =>
+ model ? predicate(model, index, array) : false
+ );
+ }
+
+ // DATA FETCHES
+
+ /**
+ * @inheritdoc
+ */
+ get canPerformSearch(): boolean {
+ if (!this.host.searchService) return false;
+
+ const trimmedQuery = this.host.baseQuery?.trim();
+ const hasNonEmptyQuery = !!trimmedQuery;
+ const isCollectionSearch = !!this.host.withinCollection;
+ const isProfileSearch = !!this.host.withinProfile;
+ const hasProfileElement = !!this.host.profileElement;
+ const isMetadataSearch = this.host.searchType === SearchType.METADATA;
+
+ // Metadata searches within a collection/profile are allowed to have no query.
+ // Otherwise, a non-empty query must be set.
+ return (
+ hasNonEmptyQuery ||
+ (isCollectionSearch && isMetadataSearch) ||
+ (isProfileSearch && hasProfileElement && isMetadataSearch)
+ );
+ }
+
+ /**
+ * The query key is a string that uniquely identifies the current search.
+ * It consists of:
+ * - The current base query
+ * - The current collection/profile target & page element
+ * - The current search type
+ * - Any currently-applied facets
+ * - Any currently-applied date range
+ * - Any currently-applied prefix filters
+ * - The current sort options
+ *
+ * This lets us keep track of queries so we don't persist data that's
+ * no longer relevant.
+ */
+ get pageFetchQueryKey(): string {
+ const profileKey = `${this.host.withinProfile}--${this.host.profileElement}`;
+ const pageTarget = this.host.withinCollection ?? profileKey;
+ const sortField = this.host.sortParam?.field ?? 'none';
+ const sortDirection = this.host.sortParam?.direction ?? 'none';
+ return `${this.fullQuery}-${pageTarget}-${this.host.searchType}-${sortField}-${sortDirection}`;
+ }
+
+ /**
+ * Similar to `pageFetchQueryKey` above, but excludes sort fields since they
+ * are not relevant in determining aggregation queries.
+ */
+ get facetFetchQueryKey(): string {
+ const profileKey = `${this.host.withinProfile}--${this.host.profileElement}`;
+ const pageTarget = this.host.withinCollection ?? profileKey;
+ return `${this.fullQuery}-${pageTarget}-${this.host.searchType}`;
+ }
+
+ /**
+ * Constructs a search service FilterMap object from the combination of
+ * all the currently-applied filters. This includes any facets, letter
+ * filters, and date range.
+ */
+ get filterMap(): FilterMap {
+ const builder = new FilterMapBuilder();
+
+ // Add the date range, if applicable
+ if (this.host.minSelectedDate) {
+ builder.addFilter(
+ 'year',
+ this.host.minSelectedDate,
+ FilterConstraint.GREATER_OR_EQUAL
+ );
+ }
+ if (this.host.maxSelectedDate) {
+ builder.addFilter(
+ 'year',
+ this.host.maxSelectedDate,
+ FilterConstraint.LESS_OR_EQUAL
+ );
+ }
+
+ // Add any selected facets
+ if (this.host.selectedFacets) {
+ for (const [facetName, facetValues] of Object.entries(
+ this.host.selectedFacets
+ )) {
+ const { name, values } = this.prepareFacetForFetch(
+ facetName,
+ facetValues
+ );
+ for (const [value, bucket] of Object.entries(values)) {
+ let constraint;
+ if (bucket.state === 'selected') {
+ constraint = FilterConstraint.INCLUDE;
+ } else if (bucket.state === 'hidden') {
+ constraint = FilterConstraint.EXCLUDE;
+ }
+
+ if (constraint) {
+ builder.addFilter(name, value, constraint);
+ }
+ }
+ }
+ }
+
+ // Add any letter filters
+ if (this.host.selectedTitleFilter) {
+ builder.addFilter(
+ 'firstTitle',
+ this.host.selectedTitleFilter,
+ FilterConstraint.INCLUDE
+ );
+ }
+ if (this.host.selectedCreatorFilter) {
+ builder.addFilter(
+ 'firstCreator',
+ this.host.selectedCreatorFilter,
+ FilterConstraint.INCLUDE
+ );
+ }
+
+ const filterMap = builder.build();
+ return filterMap;
+ }
+
+ /**
+ * Produces a compact unique ID for a search request that can help with debugging
+ * on the backend by making related requests easier to trace through different services.
+ * (e.g., tying the hits/aggregations requests for the same page back to a single hash).
+ *
+ * @param params The search service parameters for the request
+ * @param kind The kind of request (hits-only, aggregations-only, or both)
+ * @returns A Promise resolving to the uid to apply to the request
+ */
+ async requestUID(params: SearchParams, kind: RequestKind): Promise {
+ const paramsToHash = JSON.stringify({
+ pageType: params.pageType,
+ pageTarget: params.pageTarget,
+ query: params.query,
+ fields: params.fields,
+ filters: params.filters,
+ sort: params.sort,
+ searchType: this.host.searchType,
+ });
+
+ const fullQueryHash = (await sha1(paramsToHash)).slice(0, 20); // First 80 bits of SHA-1 are plenty for this
+ const sessionId = (await this.host.getSessionId()).slice(0, 20); // Likewise
+ const page = params.page ?? 0;
+ const kindPrefix = kind.charAt(0); // f = full, h = hits, a = aggregations
+ const currentTime = Date.now();
+
+ return `R:${fullQueryHash}-S:${sessionId}-P:${page}-K:${kindPrefix}-T:${currentTime}`;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ get pageSpecifierParams(): PageSpecifierParams | null {
+ if (this.host.withinCollection) {
+ return {
+ pageType: 'collection_details',
+ pageTarget: this.host.withinCollection,
+ };
+ }
+ if (this.host.withinProfile) {
+ return {
+ pageType: 'account_details',
+ pageTarget: this.host.withinProfile,
+ pageElements: this.host.profileElement
+ ? [this.host.profileElement]
+ : [],
+ };
+ }
+ return null;
+ }
+
+ /**
+ * The full query, including year facets and date range clauses
+ */
+ private get fullQuery(): string | undefined {
+ let fullQuery = this.host.baseQuery?.trim() ?? '';
+
+ const { facetQuery, dateRangeQueryClause, sortFilterQueries } = this;
+
+ if (facetQuery) {
+ fullQuery += ` AND ${facetQuery}`;
+ }
+ if (dateRangeQueryClause) {
+ fullQuery += ` AND ${dateRangeQueryClause}`;
+ }
+ if (sortFilterQueries) {
+ fullQuery += ` AND ${sortFilterQueries}`;
+ }
+ return fullQuery.trim();
+ }
+
+ /**
+ * Generates a query string representing the current set of applied facets
+ *
+ * Example: `mediatype:("collection" OR "audio" OR -"etree") AND year:("2000" OR "2001")`
+ */
+ private get facetQuery(): string | undefined {
+ if (!this.host.selectedFacets) return undefined;
+ const facetClauses = [];
+ for (const [facetName, facetValues] of Object.entries(
+ this.host.selectedFacets
+ )) {
+ facetClauses.push(this.buildFacetClause(facetName, facetValues));
+ }
+ return this.joinFacetClauses(facetClauses)?.trim();
+ }
+
+ private get dateRangeQueryClause(): string | undefined {
+ if (!this.host.minSelectedDate || !this.host.maxSelectedDate) {
+ return undefined;
+ }
+
+ return `year:[${this.host.minSelectedDate} TO ${this.host.maxSelectedDate}]`;
+ }
+
+ private get sortFilterQueries(): string {
+ const queries = [this.titleQuery, this.creatorQuery];
+ return queries.filter(q => q).join(' AND ');
+ }
+
+ /**
+ * Returns a query clause identifying the currently selected title filter,
+ * e.g., `firstTitle:X`.
+ */
+ private get titleQuery(): string | undefined {
+ return this.host.selectedTitleFilter
+ ? `firstTitle:${this.host.selectedTitleFilter}`
+ : undefined;
+ }
+
+ /**
+ * Returns a query clause identifying the currently selected creator filter,
+ * e.g., `firstCreator:X`.
+ */
+ private get creatorQuery(): string | undefined {
+ return this.host.selectedCreatorFilter
+ ? `firstCreator:${this.host.selectedCreatorFilter}`
+ : undefined;
+ }
+
+ /**
+ * Builds an OR-joined facet clause for the given facet name and values.
+ *
+ * E.g., for name `subject` and values
+ * `{ foo: { state: 'selected' }, bar: { state: 'hidden' } }`
+ * this will produce the clause
+ * `subject:("foo" OR -"bar")`.
+ *
+ * @param facetName The facet type (e.g., 'collection')
+ * @param facetValues The facet buckets, mapped by their keys
+ */
+ private buildFacetClause(
+ facetName: string,
+ facetValues: Record
+ ): string {
+ const { name: facetQueryName, values } = this.prepareFacetForFetch(
+ facetName,
+ facetValues
+ );
+ const facetEntries = Object.entries(values);
+ if (facetEntries.length === 0) return '';
+
+ const facetValuesArray: string[] = [];
+ for (const [key, facetData] of facetEntries) {
+ const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
+ facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
+ }
+
+ const valueQuery = facetValuesArray.join(` OR `);
+ return `${facetQueryName}:(${valueQuery})`;
+ }
+
+ /**
+ * Handles some special pre-request normalization steps for certain facet types
+ * that require them.
+ *
+ * @param facetName The name of the facet type (e.g., 'language')
+ * @param facetValues An array of values for that facet type
+ */
+ private prepareFacetForFetch(
+ facetName: string,
+ facetValues: Record
+ ): { name: string; values: Record } {
+ // eslint-disable-next-line prefer-const
+ let [normalizedName, normalizedValues] = [facetName, facetValues];
+
+ // The full "search engine" name of the lending field is "lending___status"
+ if (facetName === 'lending') {
+ normalizedName = 'lending___status';
+ }
+
+ return {
+ name: normalizedName,
+ values: normalizedValues,
+ };
+ }
+
+ /**
+ * Takes an array of facet clauses, and combines them into a
+ * full AND-joined facet query string. Empty clauses are ignored.
+ */
+ private joinFacetClauses(facetClauses: string[]): string | undefined {
+ const nonEmptyFacetClauses = facetClauses.filter(
+ clause => clause.length > 0
+ );
+ return nonEmptyFacetClauses.length > 0
+ ? `(${nonEmptyFacetClauses.join(' AND ')})`
+ : undefined;
+ }
+
+ /**
+ * Fires a backend request to fetch a set of aggregations (representing UI facets) for
+ * the current search state.
+ */
+ private async fetchFacets(): Promise {
+ const trimmedQuery = this.host.baseQuery?.trim();
+ if (!this.canPerformSearch) return;
+
+ const { facetFetchQueryKey } = this;
+
+ const sortParams = this.host.sortParam ? [this.host.sortParam] : [];
+ const params: SearchParams = {
+ ...this.pageSpecifierParams,
+ query: trimmedQuery || '',
+ rows: 0,
+ filters: this.filterMap,
+ // Fetch a few extra buckets beyond the 6 we show, in case some get suppressed
+ aggregationsSize: 10,
+ // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
+ // The default aggregations for the search_results page type should be what we need here.
+ };
+ params.uid = await this.requestUID(
+ { ...params, sort: sortParams },
+ 'aggregations'
+ );
+
+ this.host.setFacetsLoading(true);
+ const searchResponse = await this.host.searchService?.search(
+ params,
+ this.host.searchType
+ );
+ const success = searchResponse?.success;
+
+ // This is checking to see if the query has changed since the data was fetched.
+ // If so, we just want to discard this set of aggregations because they are
+ // likely no longer valid for the newer query.
+ const queryChangedSinceFetch =
+ facetFetchQueryKey !== this.facetFetchQueryKey;
+ if (queryChangedSinceFetch) return;
+
+ if (!success) {
+ const errorMsg = searchResponse?.error?.message;
+ const detailMsg = searchResponse?.error?.details?.message;
+
+ if (!errorMsg && !detailMsg) {
+ // @ts-ignore: Property 'Sentry' does not exist on type 'Window & typeof globalThis'
+ window?.Sentry?.captureMessage?.(
+ 'Missing or malformed facet response from backend',
+ 'error'
+ );
+ }
+
+ return;
+ }
+
+ const { aggregations, collectionTitles } = success.response;
+ this.aggregations = aggregations;
+
+ if (collectionTitles) {
+ for (const [id, title] of Object.entries(collectionTitles)) {
+ this.collectionTitles.set(id, title);
+ }
+ }
+
+ this.yearHistogramAggregation =
+ success?.response?.aggregations?.year_histogram;
+
+ this.host.setFacetsLoading(false);
+ this.host.requestUpdate();
+ }
+
+ /**
+ * Performs the initial page fetch(es) for the current search state.
+ */
+ private async doInitialPageFetch(): Promise {
+ this.host.setSearchResultsLoading(true);
+ // Try to batch 2 initial page requests when possible
+ await this.fetchPage(this.host.initialPageNumber, 2);
+ this.host.setSearchResultsLoading(false);
+ }
+
+ /**
+ * Fetches one or more pages of results and updates the data source.
+ *
+ * @param pageNumber The page number to fetch
+ * @param numInitialPages If this is an initial page fetch (`pageNumber = 1`),
+ * specifies how many pages to batch together in one request. Ignored
+ * if `pageNumber != 1`, defaulting to a single page.
+ */
+ async fetchPage(pageNumber: number, numInitialPages = 1): Promise {
+ console.log(`fetchPage(${pageNumber})`, {
+ canPerformSearch: this.canPerformSearch,
+ baseQuery: this.host.baseQuery,
+ });
+ const trimmedQuery = this.host.baseQuery?.trim();
+ if (!this.canPerformSearch) return;
+
+ console.log(`fetchPage - canPerformSearch`, {
+ hasPage: this.hasPage(pageNumber),
+ endOfDataReached: this.endOfDataReached,
+ pageFetchesInProgress: this.pageFetchesInProgress,
+ selectedFacets: JSON.stringify(this.host.selectedFacets),
+ });
+ // if we already have data, don't fetch again
+ if (this.hasPage(pageNumber)) return;
+
+ if (this.endOfDataReached) return;
+
+ // Batch multiple initial page requests together if needed (e.g., can request
+ // pages 1 and 2 together in a single request).
+ const numPages = pageNumber === 1 ? numInitialPages : 1;
+ const numRows = this.pageSize * numPages;
+
+ // if a fetch is already in progress for this query and page, don't fetch again
+ const { pageFetchQueryKey } = this;
+ const pageFetches =
+ this.pageFetchesInProgress[pageFetchQueryKey] ?? new Set();
+ if (pageFetches.has(pageNumber)) {
+ console.log(
+ `Skipping fetch for page ${pageNumber} because one is already in progress`
+ );
+ return;
+ }
+ for (let i = 0; i < numPages; i += 1) {
+ pageFetches.add(pageNumber + i);
+ }
+ this.pageFetchesInProgress[pageFetchQueryKey] = pageFetches;
+
+ const sortParams = this.host.sortParam ? [this.host.sortParam] : [];
+ const params: SearchParams = {
+ ...this.pageSpecifierParams,
+ query: trimmedQuery || '',
+ page: pageNumber,
+ rows: numRows,
+ sort: sortParams,
+ filters: this.filterMap,
+ aggregations: { omit: true },
+ };
+ params.uid = await this.requestUID(params, 'hits');
+
+ console.log('=== FIRING PAGE REQUEST ===');
+ const searchResponse = await this.host.searchService?.search(
+ params,
+ this.host.searchType
+ );
+ console.log('=== RECEIVED PAGE RESPONSE IN CB === ');
+ const success = searchResponse?.success;
+
+ // This is checking to see if the query has changed since the data was fetched.
+ // If so, we just want to discard the data since there should be a new query
+ // right behind it.
+ const queryChangedSinceFetch = pageFetchQueryKey !== this.pageFetchQueryKey;
+ if (queryChangedSinceFetch) return;
+
+ if (!success) {
+ const errorMsg = searchResponse?.error?.message;
+ const detailMsg = searchResponse?.error?.details?.message;
+
+ this.host.queryErrorMessage = `${errorMsg ?? ''}${
+ detailMsg ? `; ${detailMsg}` : ''
+ }`;
+
+ if (!this.host.queryErrorMessage) {
+ this.host.queryErrorMessage =
+ 'Missing or malformed response from backend';
+ // @ts-ignore: Property 'Sentry' does not exist on type 'Window & typeof globalThis'
+ window?.Sentry?.captureMessage?.(this.queryErrorMessage, 'error');
+ }
+
+ for (let i = 0; i < numPages; i += 1) {
+ this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber + i);
+ }
+
+ this.host.setSearchResultsLoading(false);
+ this.host.requestUpdate();
+ return;
+ }
+
+ this.totalResults = success.response.totalResults - this.offset;
+ this.host.setTotalResultCount(this.totalResults);
+
+ // display event to offshoot when result count is zero.
+ if (this.totalResults === 0) {
+ this.host.emitEmptyResults();
+ }
+
+ if (this.host.withinCollection) {
+ this.collectionExtraInfo = success.response.collectionExtraInfo;
+
+ // For collections, we want the UI to respect the default sort option
+ // which can be specified in metadata, or otherwise assumed to be week:desc
+ this.host.applyDefaultCollectionSort(this.collectionExtraInfo);
+
+ if (this.collectionExtraInfo) {
+ this.parentCollections = [].concat(
+ this.collectionExtraInfo.public_metadata?.collection ?? []
+ );
+ }
+ } else if (this.host.withinProfile) {
+ console.log(
+ 'host is within profile, setting acct info:',
+ success.response.accountExtraInfo
+ );
+ this.accountExtraInfo = success.response.accountExtraInfo;
+ this.pageElements = success.response.pageElements;
+ }
+
+ const { results, collectionTitles } = success.response;
+ if (results && results.length > 0) {
+ // Load any collection titles present on the response into the cache,
+ // or queue up preload fetches for them if none were present.
+ if (collectionTitles) {
+ for (const [id, title] of Object.entries(collectionTitles)) {
+ this.collectionTitles.set(id, title);
+ }
+ }
+
+ // Update the data source for each returned page
+ for (let i = 0; i < numPages; i += 1) {
+ const pageStartIndex = this.pageSize * i;
+ this.addTilesToDataSource(
+ pageNumber + i,
+ results.slice(pageStartIndex, pageStartIndex + this.pageSize)
+ );
+ }
+ }
+
+ // When we reach the end of the data, we can set the infinite scroller's
+ // item count to the real total number of results (rather than the
+ // temporary estimates based on pages rendered so far).
+ const resultCountDiscrepancy = numRows - results.length;
+ if (resultCountDiscrepancy > 0) {
+ this.endOfDataReached = true;
+ this.host.setTileCount(this.totalResults);
+ }
+
+ for (let i = 0; i < numPages; i += 1) {
+ this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber + i);
+ }
+
+ this.host.requestUpdate();
+ }
+
+ /**
+ * Update the datasource from the fetch response
+ *
+ * @param pageNumber
+ * @param results
+ */
+ private addTilesToDataSource(
+ pageNumber: number,
+ results: SearchResult[]
+ ): void {
+ // copy our existing datasource so when we set it below, it gets set
+ // instead of modifying the existing dataSource since object changes
+ // don't trigger a re-render
+ // const datasource = { ...this.dataSource };
+ const tiles: TileModel[] = [];
+ results?.forEach(result => {
+ if (!result.identifier) return;
+
+ let loginRequired = false;
+ let contentWarning = false;
+ // Check if item and item in "modifying" collection, setting above flags
+ if (
+ result.collection?.values.length &&
+ result.mediatype?.value !== 'collection'
+ ) {
+ for (const collection of result.collection?.values ?? []) {
+ if (collection === 'loggedin') {
+ loginRequired = true;
+ if (contentWarning) break;
+ }
+ if (collection === 'no-preview') {
+ contentWarning = true;
+ if (loginRequired) break;
+ }
+ }
+ }
+
+ tiles.push({
+ averageRating: result.avg_rating?.value,
+ checked: false,
+ collections: result.collection?.values ?? [],
+ collectionFilesCount: result.collection_files_count?.value ?? 0,
+ collectionSize: result.collection_size?.value ?? 0,
+ commentCount: result.num_reviews?.value ?? 0,
+ creator: result.creator?.value,
+ creators: result.creator?.values ?? [],
+ dateAdded: result.addeddate?.value,
+ dateArchived: result.publicdate?.value,
+ datePublished: result.date?.value,
+ dateReviewed: result.reviewdate?.value,
+ description: result.description?.values.join('\n'),
+ favCount: result.num_favorites?.value ?? 0,
+ href: this.collapseRepeatedQuotes(result.__href__?.value),
+ identifier: result.identifier,
+ issue: result.issue?.value,
+ itemCount: result.item_count?.value ?? 0,
+ mediatype: this.getMediatype(result),
+ snippets: result.highlight?.values ?? [],
+ source: result.source?.value,
+ subjects: result.subject?.values ?? [],
+ title: result.title?.value ?? '',
+ volume: result.volume?.value,
+ viewCount: result.downloads?.value ?? 0,
+ weeklyViewCount: result.week?.value,
+ loginRequired,
+ contentWarning,
+ });
+ });
+ this.addPage(pageNumber, tiles);
+ const visiblePages = this.host.currentVisiblePageNumbers;
+ const needsReload = visiblePages.includes(pageNumber);
+ if (needsReload) {
+ this.host.refreshVisibleResults();
+ }
+ }
+
+ /**
+ * Returns the mediatype string for the given search result, taking into account
+ * the special `favorited_search` hit type.
+ * @param result The search result to extract a mediatype from
+ */
+ private getMediatype(result: SearchResult): MediaType {
+ /**
+ * hit_type == 'favorited_search' is basically a new hit_type
+ * - we are getting from PPS.
+ * - which gives response for fav- collection
+ * - having favorited items like account/collection/item etc..
+ * - as user can also favorite a search result (a search page)
+ * - so we need to have response (having fav- items and fav- search results)
+ *
+ * if backend hit_type == 'favorited_search'
+ * - let's assume a "search" as new mediatype
+ */
+ if (result?.rawMetadata?.hit_type === 'favorited_search') {
+ return 'search';
+ }
+
+ return result.mediatype?.value ?? 'data';
+ }
+
+ /**
+ * Returns the input string, but removing one set of quotes from all instances of
+ * ""clauses wrapped in two sets of quotes"". This assumes the quotes are already
+ * URL-encoded.
+ *
+ * This should be a temporary measure to address the fact that the __href__ field
+ * sometimes acquires extra quotation marks during query rewriting. Once there is a
+ * full Lucene parser in place that handles quoted queries correctly, this can likely
+ * be removed.
+ */
+ private collapseRepeatedQuotes(str?: string): string | undefined {
+ return str?.replace(/%22%22(?!%22%22)(.+?)%22%22/g, '%22$1%22');
+ }
+
+ /**
+ * Fetches the aggregation buckets for the given prefix filter type.
+ */
+ private async fetchPrefixFilterBuckets(
+ filterType: PrefixFilterType
+ ): Promise {
+ const trimmedQuery = this.host.baseQuery?.trim();
+ if (!this.canPerformSearch) return [];
+
+ const filterAggregationKey = prefixFilterAggregationKeys[filterType];
+ const sortParams = this.host.sortParam ? [this.host.sortParam] : [];
+
+ const params: SearchParams = {
+ ...this.pageSpecifierParams,
+ query: trimmedQuery || '',
+ rows: 0,
+ filters: this.filterMap,
+ // Only fetch the firstTitle or firstCreator aggregation
+ aggregations: { simpleParams: [filterAggregationKey] },
+ // Fetch all 26 letter buckets
+ aggregationsSize: 26,
+ };
+ params.uid = await this.requestUID(
+ { ...params, sort: sortParams },
+ 'aggregations'
+ );
+
+ const searchResponse = await this.host.searchService?.search(
+ params,
+ this.host.searchType
+ );
+
+ return (searchResponse?.success?.response?.aggregations?.[
+ filterAggregationKey
+ ]?.buckets ?? []) as Bucket[];
+ }
+
+ /**
+ * Fetches and caches the prefix filter counts for the given filter type.
+ */
+ async updatePrefixFilterCounts(filterType: PrefixFilterType): Promise {
+ const { facetFetchQueryKey } = this;
+ const buckets = await this.fetchPrefixFilterBuckets(filterType);
+
+ // Don't update the filter counts for an outdated query (if it has been changed
+ // since we sent the request)
+ const queryChangedSinceFetch =
+ facetFetchQueryKey !== this.facetFetchQueryKey;
+ if (queryChangedSinceFetch) return;
+
+ // Unpack the aggregation buckets into a simple map like { 'A': 50, 'B': 25, ... }
+ this.prefixFilterCountMap = { ...this.prefixFilterCountMap }; // Clone the object to trigger an update
+ this.prefixFilterCountMap[filterType] = buckets.reduce(
+ (acc: Record, bucket: Bucket) => {
+ acc[(bucket.key as string).toUpperCase()] = bucket.doc_count;
+ return acc;
+ },
+ {}
+ );
+
+ this.host.requestUpdate();
+ }
+}
diff --git a/src/data-source/models.ts b/src/data-source/models.ts
new file mode 100644
index 000000000..135bc5328
--- /dev/null
+++ b/src/data-source/models.ts
@@ -0,0 +1,75 @@
+import type {
+ CollectionExtraInfo,
+ PageType,
+ SearchServiceInterface,
+ SearchType,
+ SortDirection,
+ SortParam,
+} from '@internetarchive/search-service';
+import type { SelectedFacets, SortField } from '../models';
+
+/**
+ * A Map from collection identifiers to their corresponding collection titles.
+ */
+export type CollectionTitles = Map;
+
+/**
+ * The subset of search service params that uniquely specify the type of results
+ * that are sought by an instance of collection browser.
+ */
+export type PageSpecifierParams = {
+ /**
+ * What high-level type of page is being fetched for (search results, collection, or profile)
+ */
+ pageType: PageType;
+ /**
+ * The target identifier for collection or profile pages (e.g., "prelinger", "@brewster", ...)
+ */
+ pageTarget: string;
+ /**
+ * Which specific elements of a profile page to fetch. Corresponds to individual tab data
+ * (e.g., "uploads", "reviews", ...)
+ */
+ pageElements?: string[];
+};
+
+/**
+ * Properties of collection browser that affect the overall search query
+ */
+export interface CollectionBrowserQueryState {
+ baseQuery?: string;
+ withinCollection?: string;
+ withinProfile?: string;
+ profileElement?: string;
+ searchType: SearchType;
+ selectedFacets?: SelectedFacets;
+ minSelectedDate?: string;
+ maxSelectedDate?: string;
+ selectedTitleFilter: string | null;
+ selectedCreatorFilter: string | null;
+ selectedSort?: SortField;
+ sortDirection: SortDirection | null;
+}
+
+/**
+ * Interface representing search-related state and operations required by the
+ * data source on its host component.
+ */
+export interface CollectionBrowserSearchInterface
+ extends CollectionBrowserQueryState {
+ searchService?: SearchServiceInterface;
+ queryErrorMessage?: string;
+ readonly sortParam: SortParam | null;
+ readonly suppressFacets?: boolean;
+ readonly initialPageNumber: number;
+ readonly currentVisiblePageNumbers: number[];
+
+ getSessionId(): Promise;
+ setSearchResultsLoading(loading: boolean): void;
+ setFacetsLoading(loading: boolean): void;
+ setTotalResultCount(count: number): void;
+ setTileCount(count: number): void;
+ applyDefaultCollectionSort(collectionInfo?: CollectionExtraInfo): void;
+ emitEmptyResults(): void;
+ refreshVisibleResults(): void;
+}
diff --git a/src/sort-filter-bar/sort-filter-bar.ts b/src/sort-filter-bar/sort-filter-bar.ts
index 184a9981f..87529983c 100644
--- a/src/sort-filter-bar/sort-filter-bar.ts
+++ b/src/sort-filter-bar/sort-filter-bar.ts
@@ -216,28 +216,37 @@ export class SortFilterBar
private disconnectResizeObserver(
resizeObserver: SharedResizeObserverInterface
) {
- resizeObserver.removeObserver({
- target: this.sortSelectorContainer,
- handler: this,
- });
+ if (this.sortSelectorContainer) {
+ resizeObserver.removeObserver({
+ target: this.sortSelectorContainer,
+ handler: this,
+ });
+ }
- resizeObserver.removeObserver({
- target: this.desktopSortContainer,
- handler: this,
- });
+ if (this.desktopSortContainer) {
+ resizeObserver.removeObserver({
+ target: this.desktopSortContainer,
+ handler: this,
+ });
+ }
}
private setupResizeObserver() {
if (!this.resizeObserver) return;
- this.resizeObserver.addObserver({
- target: this.sortSelectorContainer,
- handler: this,
- });
- this.resizeObserver.addObserver({
- target: this.desktopSortContainer,
- handler: this,
- });
+ if (this.sortSelectorContainer) {
+ this.resizeObserver.addObserver({
+ target: this.sortSelectorContainer,
+ handler: this,
+ });
+ }
+
+ if (this.desktopSortContainer) {
+ this.resizeObserver.addObserver({
+ target: this.desktopSortContainer,
+ handler: this,
+ });
+ }
}
handleResize(entry: ResizeObserverEntry): void {
diff --git a/src/tiles/hover/hover-pane-controller.ts b/src/tiles/hover/hover-pane-controller.ts
index 86481230f..f59192efb 100644
--- a/src/tiles/hover/hover-pane-controller.ts
+++ b/src/tiles/hover/hover-pane-controller.ts
@@ -1,4 +1,3 @@
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import type { SortParam } from '@internetarchive/search-service';
import {
html,
@@ -8,6 +7,7 @@ import {
ReactiveControllerHost,
} from 'lit';
import type { TileModel } from '../../models';
+import type { CollectionTitles } from '../../data-source/models';
type HoverPaneState = 'hidden' | 'shown' | 'fading-out';
@@ -17,7 +17,7 @@ export interface HoverPaneProperties {
baseImageUrl?: string;
loggedIn: boolean;
sortParam: SortParam | null;
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
}
export interface HoverPaneControllerOptions {
@@ -187,7 +187,7 @@ export class HoverPaneController implements HoverPaneControllerInterface {
.baseImageUrl=${this.hoverPaneProps?.baseImageUrl}
.loggedIn=${this.hoverPaneProps?.loggedIn}
.sortParam=${this.hoverPaneProps?.sortParam}
- .collectionNameCache=${this.hoverPaneProps?.collectionNameCache}
+ .collectionTitles=${this.hoverPaneProps?.collectionTitles}
>`
: nothing;
}
diff --git a/src/tiles/hover/tile-hover-pane.ts b/src/tiles/hover/tile-hover-pane.ts
index 57f424430..ff46bced0 100644
--- a/src/tiles/hover/tile-hover-pane.ts
+++ b/src/tiles/hover/tile-hover-pane.ts
@@ -1,8 +1,8 @@
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import type { SortParam } from '@internetarchive/search-service';
import { css, CSSResultGroup, html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { TileModel } from '../../models';
+import type { CollectionTitles } from '../../data-source/models';
import '../list/tile-list';
@customElement('tile-hover-pane')
@@ -18,7 +18,7 @@ export class TileHoverPane extends LitElement {
@property({ type: Object }) sortParam?: SortParam;
@property({ type: Object })
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
protected render(): TemplateResult {
return html`
@@ -29,7 +29,7 @@ export class TileHoverPane extends LitElement {
.baseImageUrl=${this.baseImageUrl}
.loggedIn=${this.loggedIn}
.sortParam=${this.sortParam}
- .collectionNameCache=${this.collectionNameCache}
+ .collectionTitles=${this.collectionTitles}
>
`;
diff --git a/src/tiles/list/tile-list.ts b/src/tiles/list/tile-list.ts
index 008bdc87e..65e95fe12 100644
--- a/src/tiles/list/tile-list.ts
+++ b/src/tiles/list/tile-list.ts
@@ -7,8 +7,8 @@ import { customElement, property, state } from 'lit/decorators.js';
import { msg } from '@lit/localize';
import DOMPurify from 'dompurify';
-import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import { suppressedCollections } from '../../models';
+import type { CollectionTitles } from '../../data-source/models';
import { BaseTileComponent } from '../base-tile-component';
import { formatCount, NumberFormat } from '../../utils/format-count';
@@ -35,7 +35,7 @@ export class TileList extends BaseTileComponent {
*/
@property({ type: Object })
- collectionNameCache?: CollectionNameCacheInterface;
+ collectionTitles?: CollectionTitles;
@state() private collectionLinks: TemplateResult[] = [];
@@ -379,40 +379,35 @@ export class TileList extends BaseTileComponent {
}
protected updated(changed: PropertyValues): void {
- if (changed.has('model')) {
- this.fetchCollectionNames();
+ if (changed.has('model') || changed.has('collectionTitles')) {
+ this.buildCollectionLinks();
}
}
- private async fetchCollectionNames() {
- if (
- !this.model?.collections ||
- this.model.collections.length === 0 ||
- !this.collectionNameCache
- ) {
+ private async buildCollectionLinks() {
+ if (!this.model?.collections || this.model.collections.length === 0) {
return;
}
+
// Note: quirk of Lit: need to replace collectionLinks array,
// otherwise it will not re-render. Can't simply alter the array.
this.collectionLinks = [];
const newCollectionLinks: TemplateResult[] = [];
- const promises: Promise