@internetarchive/collection-browser 0.3.7-alpha.1 → 0.3.8

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 (31) hide show
  1. package/dist/src/app-root.d.ts +1 -0
  2. package/dist/src/app-root.js +29 -8
  3. package/dist/src/app-root.js.map +1 -1
  4. package/dist/src/collection-browser.d.ts +33 -1
  5. package/dist/src/collection-browser.js +118 -44
  6. package/dist/src/collection-browser.js.map +1 -1
  7. package/dist/src/collection-facets/more-facets-content.js +3 -3
  8. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  9. package/dist/src/collection-facets.js +1 -1
  10. package/dist/src/collection-facets.js.map +1 -1
  11. package/dist/src/tiles/list/tile-list.js +6 -3
  12. package/dist/src/tiles/list/tile-list.js.map +1 -1
  13. package/dist/test/collection-browser.test.js +139 -0
  14. package/dist/test/collection-browser.test.js.map +1 -1
  15. package/dist/test/mocks/mock-search-responses.d.ts +2 -0
  16. package/dist/test/mocks/mock-search-responses.js +74 -1
  17. package/dist/test/mocks/mock-search-responses.js.map +1 -1
  18. package/dist/test/mocks/mock-search-service.js +5 -1
  19. package/dist/test/mocks/mock-search-service.js.map +1 -1
  20. package/dist/test/tiles/list/tile-list.test.js +29 -0
  21. package/dist/test/tiles/list/tile-list.test.js.map +1 -1
  22. package/package.json +3 -3
  23. package/src/app-root.ts +33 -10
  24. package/src/collection-browser.ts +139 -44
  25. package/src/collection-facets/more-facets-content.ts +3 -3
  26. package/src/collection-facets.ts +1 -1
  27. package/src/tiles/list/tile-list.ts +6 -2
  28. package/test/collection-browser.test.ts +207 -0
  29. package/test/mocks/mock-search-responses.ts +82 -0
  30. package/test/mocks/mock-search-service.ts +6 -0
  31. package/test/tiles/list/tile-list.test.ts +40 -0
package/src/app-root.ts CHANGED
@@ -72,12 +72,13 @@ export class AppRoot extends LitElement {
72
72
  private sendAnalytics(ae: AnalyticsEvent) {
73
73
  console.log('Analytics Received ----', ae);
74
74
  this.latestAction = ae;
75
- this.analyticsManager?.sendEventNoSampling(ae);
75
+ this.analyticsManager?.sendEvent(ae);
76
76
  }
77
77
 
78
78
  private initSearchServiceFromUrlParams() {
79
79
  const params = new URL(window.location.href).searchParams;
80
80
  return new SearchService({
81
+ includeCredentials: false,
81
82
  baseUrl: params.get('search_base_url') ?? undefined,
82
83
  servicePath: params.get('search_service_path') ?? undefined,
83
84
  debuggingEnabled: !!params.get('debugging') ?? undefined,
@@ -160,15 +161,15 @@ export class AppRoot extends LitElement {
160
161
  <div id="toggle-controls">
161
162
  <button
162
163
  @click=${() => {
163
- const details =
164
- this.shadowRoot?.getElementById('cell-size-control');
165
- details?.classList.toggle('hidden');
166
- const rowGapControls =
167
- this.shadowRoot?.getElementById('cell-gap-control');
168
- rowGapControls?.classList.toggle('hidden');
164
+ const cellControls =
165
+ this.shadowRoot?.getElementById('cell-controls');
166
+ cellControls?.classList.toggle('hidden');
167
+ const checkboxControls =
168
+ this.shadowRoot?.getElementById('checkbox-controls');
169
+ checkboxControls?.classList.toggle('hidden');
169
170
  }}
170
171
  >
171
- Toggle Cell Controls
172
+ Toggle Controls
172
173
  </button>
173
174
  <button
174
175
  @click=${() => {
@@ -189,7 +190,7 @@ export class AppRoot extends LitElement {
189
190
  >
190
191
  </div>
191
192
 
192
- <div id="cell-controls" class="hidden">
193
+ <div id="cell-controls">
193
194
  <div id="cell-size-control">
194
195
  <div>
195
196
  <label for="cell-width-slider">Min cell width:</label>
@@ -282,6 +283,15 @@ export class AppRoot extends LitElement {
282
283
  />
283
284
  <label for="show-dummy-snippets">Show dummy snippets</label>
284
285
  </div>
286
+ <div class="checkbox-control">
287
+ <input
288
+ type="checkbox"
289
+ id="enable-date-picker"
290
+ checked
291
+ @click=${this.datePickerChanged}
292
+ />
293
+ <label for="enable-date-picker">Enable date picker</label>
294
+ </div>
285
295
  </div>
286
296
  </div>
287
297
 
@@ -409,6 +419,18 @@ export class AppRoot extends LitElement {
409
419
  this.searchQuery = oldQuery; // Re-apply the original query
410
420
  }
411
421
 
422
+ private datePickerChanged(e: Event) {
423
+ const target = e.target as HTMLInputElement;
424
+ this.collectionBrowser.showHistogramDatePicker = target.checked;
425
+
426
+ // When disabling the date picker from the demo app, also clear any existing date range params
427
+ if (!this.collectionBrowser.showHistogramDatePicker) {
428
+ this.collectionBrowser.minSelectedDate = undefined;
429
+ this.collectionBrowser.maxSelectedDate = undefined;
430
+ this.collectionBrowser.dateRangeQueryClause = undefined;
431
+ }
432
+ }
433
+
412
434
  private rowGapChanged(e: Event) {
413
435
  const input = e.target as HTMLInputElement;
414
436
  this.rowGap = parseFloat(input.value);
@@ -563,7 +585,8 @@ export class AppRoot extends LitElement {
563
585
  }
564
586
 
565
587
  .hidden {
566
- display: none;
588
+ /* If this class is present, we want the element hidden regardless of specificity */
589
+ display: none !important;
567
590
  }
568
591
 
569
592
  #toggle-controls {
@@ -46,6 +46,7 @@ import {
46
46
  TileModel,
47
47
  CollectionDisplayMode,
48
48
  FacetOption,
49
+ FacetBucket,
49
50
  } from './models';
50
51
  import {
51
52
  RestorationStateHandlerInterface,
@@ -404,7 +405,7 @@ export class CollectionBrowser
404
405
  private sendSortByAnalytics(prevSortDirection: SortDirection | null): void {
405
406
  const directionCleared = prevSortDirection && !this.sortDirection;
406
407
 
407
- this.analyticsHandler?.sendEventNoSampling({
408
+ this.analyticsHandler?.sendEvent({
408
409
  category: this.searchContext,
409
410
  action: analyticsActions.sortBy,
410
411
  label: `${this.selectedSort}${
@@ -430,7 +431,7 @@ export class CollectionBrowser
430
431
  this.displayMode = e.detail.displayMode;
431
432
 
432
433
  if (this.displayMode) {
433
- this.analyticsHandler?.sendEventNoSampling({
434
+ this.analyticsHandler?.sendEvent({
434
435
  category: this.searchContext,
435
436
  action: analyticsActions.displayMode,
436
437
  label: this.displayMode,
@@ -447,7 +448,7 @@ export class CollectionBrowser
447
448
  }
448
449
  const cleared = prevSelectedLetter && this.selectedTitleFilter === null;
449
450
 
450
- this.analyticsHandler?.sendEventNoSampling({
451
+ this.analyticsHandler?.sendEvent({
451
452
  category: this.searchContext,
452
453
  action: analyticsActions.filterByTitle,
453
454
  label: cleared
@@ -473,7 +474,7 @@ export class CollectionBrowser
473
474
  }
474
475
  const cleared = prevSelectedLetter && this.selectedCreatorFilter === null;
475
476
 
476
- this.analyticsHandler?.sendEventNoSampling({
477
+ this.analyticsHandler?.sendEvent({
477
478
  category: this.searchContext,
478
479
  action: analyticsActions.filterByCreator,
479
480
  label: cleared
@@ -597,10 +598,12 @@ export class CollectionBrowser
597
598
  }>
598
599
  ) {
599
600
  const { minDate, maxDate } = e.detail;
601
+
602
+ [this.minSelectedDate, this.maxSelectedDate] = [minDate, maxDate];
600
603
  this.dateRangeQueryClause = `year:[${minDate} TO ${maxDate}]`;
601
604
 
602
605
  if (this.dateRangeQueryClause) {
603
- this.analyticsHandler?.sendEventNoSampling({
606
+ this.analyticsHandler?.sendEvent({
604
607
  category: this.searchContext,
605
608
  action: analyticsActions.histogramChanged,
606
609
  label: this.dateRangeQueryClause,
@@ -775,6 +778,11 @@ export class CollectionBrowser
775
778
  this.pageFetchesInProgress = {};
776
779
  this.endOfDataReached = false;
777
780
  this.pagesToRender = this.initialPageNumber;
781
+
782
+ // Reset the infinite scroller's item count, so that it
783
+ // shows tile placeholders until the new query's results load in
784
+ this.infiniteScroller?.reload();
785
+
778
786
  if (!this.initialQueryChangeHappened && this.initialPageNumber > 1) {
779
787
  this.scrollToPage(this.initialPageNumber);
780
788
  }
@@ -790,7 +798,8 @@ export class CollectionBrowser
790
798
  await Promise.all([
791
799
  this.doInitialPageFetch(),
792
800
  this.fetchFacets(),
793
- this.fetchFullYearHistogram(),
801
+ // Only fetch histogram data separately if we need it b/c of date filters
802
+ this.shouldRequestYearHistogram && this.fetchFullYearHistogram(),
794
803
  ]);
795
804
  }
796
805
 
@@ -858,21 +867,34 @@ export class CollectionBrowser
858
867
  this.searchResultsLoading = false;
859
868
  }
860
869
 
870
+ /** The full query, including year facets and date range clauses */
861
871
  private get fullQuery(): string | undefined {
862
- let { fullQueryWithoutDate } = this;
863
- const { dateRangeQueryClause } = this;
872
+ if (!this.baseQuery) return undefined;
873
+ let fullQuery = this.baseQuery;
874
+
875
+ const { facetQuery, dateRangeQueryClause, sortFilterQueries } = this;
876
+
877
+ if (facetQuery) {
878
+ fullQuery += ` AND ${facetQuery}`;
879
+ }
864
880
  if (dateRangeQueryClause) {
865
- fullQueryWithoutDate += ` AND ${dateRangeQueryClause}`;
881
+ fullQuery += ` AND ${dateRangeQueryClause}`;
866
882
  }
867
- return fullQueryWithoutDate;
883
+ if (sortFilterQueries) {
884
+ fullQuery += ` AND ${sortFilterQueries}`;
885
+ }
886
+ return fullQuery;
868
887
  }
869
888
 
870
- private get fullQueryWithoutDate(): string | undefined {
889
+ /** The full query without any year facets or date range clauses */
890
+ private get fullQueryWithoutDates(): string | undefined {
871
891
  if (!this.baseQuery) return undefined;
872
892
  let fullQuery = this.baseQuery;
873
- const { facetQuery, sortFilterQueries } = this;
874
- if (facetQuery) {
875
- fullQuery += ` AND ${facetQuery}`;
893
+
894
+ const { facetQueryWithoutYear, sortFilterQueries } = this;
895
+
896
+ if (facetQueryWithoutYear) {
897
+ fullQuery += ` AND ${facetQueryWithoutYear}`;
876
898
  }
877
899
  if (sortFilterQueries) {
878
900
  fullQuery += ` AND ${sortFilterQueries}`;
@@ -887,33 +909,84 @@ export class CollectionBrowser
887
909
  */
888
910
  private get facetQuery(): string | undefined {
889
911
  if (!this.selectedFacets) return undefined;
890
- const facetQuery = [];
912
+ const facetClauses = [];
891
913
  for (const [facetName, facetValues] of Object.entries(
892
914
  this.selectedFacets
893
915
  )) {
894
- const facetEntries = Object.entries(facetValues);
895
- const facetQueryName =
896
- facetName === 'lending' ? 'lending___status' : facetName;
897
- // eslint-disable-next-line no-continue
898
- if (facetEntries.length === 0) continue;
899
- const facetValuesArray: string[] = [];
900
- for (const [key, facetData] of facetEntries) {
901
- const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
902
-
903
- if (facetName === 'language') {
904
- const languages =
905
- this.languageCodeHandler.getCodeArrayFromCodeString(key);
906
- for (const language of languages) {
907
- facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
908
- }
909
- } else {
910
- facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
916
+ facetClauses.push(this.buildFacetClause(facetName, facetValues));
917
+ }
918
+ return this.joinFacetClauses(facetClauses);
919
+ }
920
+
921
+ /**
922
+ * Generates a query string for the currently selected facets, excluding 'year' facets.
923
+ *
924
+ * Example: `mediatype:("collection" OR "audio" OR -"etree") AND subject:("foo" OR -"bar")`
925
+ */
926
+ private get facetQueryWithoutYear(): string | undefined {
927
+ if (!this.selectedFacets) return undefined;
928
+ const facetClauses = [];
929
+ for (const [facetName, facetValues] of Object.entries(
930
+ this.selectedFacets
931
+ )) {
932
+ if (facetName !== 'year') {
933
+ facetClauses.push(this.buildFacetClause(facetName, facetValues));
934
+ }
935
+ }
936
+ return this.joinFacetClauses(facetClauses);
937
+ }
938
+
939
+ /**
940
+ * Builds an OR-joined facet clause for the given facet name and values.
941
+ *
942
+ * E.g., for name `subject` and values
943
+ * `{ foo: { state: 'selected' }, bar: { state: 'hidden' } }`
944
+ * this will produce the clause
945
+ * `subject:("foo" OR -"bar")`.
946
+ *
947
+ * @param facetName The facet type (e.g., 'collection')
948
+ * @param facetValues The facet buckets, mapped by their keys
949
+ */
950
+ private buildFacetClause(
951
+ facetName: string,
952
+ facetValues: Record<string, FacetBucket>
953
+ ): string {
954
+ const facetEntries = Object.entries(facetValues);
955
+ if (facetEntries.length === 0) return '';
956
+
957
+ const facetQueryName =
958
+ facetName === 'lending' ? 'lending___status' : facetName;
959
+ const facetValuesArray: string[] = [];
960
+
961
+ for (const [key, facetData] of facetEntries) {
962
+ const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
963
+
964
+ if (facetName === 'language') {
965
+ const languages =
966
+ this.languageCodeHandler.getCodeArrayFromCodeString(key);
967
+ for (const language of languages) {
968
+ facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
911
969
  }
970
+ } else {
971
+ facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
912
972
  }
913
- const valueQuery = facetValuesArray.join(` OR `);
914
- facetQuery.push(`${facetQueryName}:(${valueQuery})`);
915
973
  }
916
- return facetQuery.length > 0 ? `(${facetQuery.join(' AND ')})` : undefined;
974
+
975
+ const valueQuery = facetValuesArray.join(` OR `);
976
+ return `${facetQueryName}:(${valueQuery})`;
977
+ }
978
+
979
+ /**
980
+ * Takes an array of facet clauses, and combines them into a
981
+ * full AND-joined facet query string. Empty clauses are ignored.
982
+ */
983
+ private joinFacetClauses(facetClauses: string[]): string | undefined {
984
+ const nonEmptyFacetClauses = facetClauses.filter(
985
+ clause => clause.length > 0
986
+ );
987
+ return nonEmptyFacetClauses.length > 0
988
+ ? `(${nonEmptyFacetClauses.join(' AND ')})`
989
+ : undefined;
917
990
  }
918
991
 
919
992
  facetsChanged(e: CustomEvent<SelectedFacets>) {
@@ -926,7 +999,7 @@ export class CollectionBrowser
926
999
  negative: boolean
927
1000
  ): void {
928
1001
  if (negative) {
929
- this.analyticsHandler?.sendEventNoSampling({
1002
+ this.analyticsHandler?.sendEvent({
930
1003
  category: this.searchContext,
931
1004
  action: facetSelected
932
1005
  ? analyticsActions.facetNegativeSelected
@@ -934,7 +1007,7 @@ export class CollectionBrowser
934
1007
  label: name,
935
1008
  });
936
1009
  } else {
937
- this.analyticsHandler?.sendEventNoSampling({
1010
+ this.analyticsHandler?.sendEvent({
938
1011
  category: this.searchContext,
939
1012
  action: facetSelected
940
1013
  ? analyticsActions.facetSelected
@@ -962,6 +1035,13 @@ export class CollectionBrowser
962
1035
  this.facetsLoading = false;
963
1036
 
964
1037
  this.aggregations = results?.success?.response.aggregations;
1038
+
1039
+ // If we're not fetching year histogram data separately, set it from the newly-fetched aggregations
1040
+ if (!this.shouldRequestYearHistogram) {
1041
+ this.fullYearsHistogramAggregation =
1042
+ results?.success?.response?.aggregations?.year_histogram ??
1043
+ results?.success?.response?.aggregations?.['year-histogram']; // Temp fix until PPS FTS key is fixed to use underscore
1044
+ }
965
1045
  }
966
1046
 
967
1047
  /**
@@ -980,7 +1060,7 @@ export class CollectionBrowser
980
1060
  * If this doesn't change, we don't need to re-fetch the histogram date range
981
1061
  */
982
1062
  private get fullQueryNoDateKey() {
983
- return `${this.fullQueryWithoutDate}-${this.searchType}-${this.sortParam?.field}-${this.sortParam?.direction}`;
1063
+ return `${this.fullQueryWithoutDates}-${this.searchType}-${this.sortParam?.field}-${this.sortParam?.direction}`;
984
1064
  }
985
1065
 
986
1066
  /**
@@ -993,10 +1073,11 @@ export class CollectionBrowser
993
1073
  private async fetchFullYearHistogram(): Promise<void> {
994
1074
  const { fullQueryNoDateKey } = this;
995
1075
  if (
996
- !this.fullQueryWithoutDate ||
1076
+ !this.fullQueryWithoutDates ||
997
1077
  fullQueryNoDateKey === this.previousFullQueryNoDate
998
- )
1078
+ ) {
999
1079
  return;
1080
+ }
1000
1081
  this.previousFullQueryNoDate = fullQueryNoDateKey;
1001
1082
 
1002
1083
  const aggregations = {
@@ -1004,7 +1085,7 @@ export class CollectionBrowser
1004
1085
  };
1005
1086
 
1006
1087
  const params = {
1007
- query: this.fullQueryWithoutDate,
1088
+ query: this.fullQueryWithoutDates,
1008
1089
  aggregations,
1009
1090
  rows: 0,
1010
1091
  };
@@ -1018,6 +1099,20 @@ export class CollectionBrowser
1018
1099
  results?.success?.response?.aggregations?.['year-histogram']; // Temp fix until PPS FTS key is fixed to use underscore
1019
1100
  }
1020
1101
 
1102
+ /**
1103
+ * We only want to send a separate request for the year_histogram data
1104
+ * if (a) the date picker component is enabled and (b) there is a year facet or date-range filter applied.
1105
+ *
1106
+ * Otherwise, we should just be using the histogram data supplied by the "normal" facet request.
1107
+ */
1108
+ private get shouldRequestYearHistogram() {
1109
+ const datePickerEnabled = this.showHistogramDatePicker;
1110
+ const hasDateRange = !!this.dateRangeQueryClause;
1111
+ const hasYearFacet =
1112
+ Object.keys(this.selectedFacets?.year ?? {}).length > 0;
1113
+ return datePickerEnabled && (hasDateRange || hasYearFacet);
1114
+ }
1115
+
1021
1116
  private scrollToPage(pageNumber: number) {
1022
1117
  const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
1023
1118
  // without this setTimeout, Safari just pauses until the `fetchPage` is complete
@@ -1195,7 +1290,7 @@ export class CollectionBrowser
1195
1290
  dateArchived: result.publicdate?.value,
1196
1291
  datePublished: result.date?.value,
1197
1292
  dateReviewed: result.reviewdate?.value,
1198
- description: result.description?.value,
1293
+ description: result.description?.values.join('\n'),
1199
1294
  favCount: result.num_favorites?.value ?? 0,
1200
1295
  identifier: result.identifier,
1201
1296
  issue: result.issue?.value,
@@ -1251,13 +1346,13 @@ export class CollectionBrowser
1251
1346
  * Callback when a result is selected
1252
1347
  */
1253
1348
  resultSelected(event: CustomEvent<TileModel>): void {
1254
- this.analyticsHandler?.sendEventNoSampling({
1349
+ this.analyticsHandler?.sendEvent({
1255
1350
  category: this.searchContext,
1256
1351
  action: analyticsActions.resultSelected,
1257
1352
  label: event.detail.mediatype,
1258
1353
  });
1259
1354
 
1260
- this.analyticsHandler?.sendEventNoSampling({
1355
+ this.analyticsHandler?.sendEvent({
1261
1356
  category: this.searchContext,
1262
1357
  action: analyticsActions.resultSelected,
1263
1358
  label: `page-${this.currentPage}`,
@@ -146,7 +146,7 @@ export class MoreFacetsContent extends LitElement {
146
146
  this.pageNumber = Number(page);
147
147
  }
148
148
 
149
- this.analyticsHandler?.sendEventNoSampling({
149
+ this.analyticsHandler?.sendEvent({
150
150
  category: analyticsCategories.default,
151
151
  action: analyticsActions.moreFacetsPageChange,
152
152
  label: `${this.pageNumber}`,
@@ -437,7 +437,7 @@ export class MoreFacetsContent extends LitElement {
437
437
  });
438
438
  this.dispatchEvent(event);
439
439
  this.modalManager?.closeModal();
440
- this.analyticsHandler?.sendEventNoSampling({
440
+ this.analyticsHandler?.sendEvent({
441
441
  category: analyticsCategories.default,
442
442
  action: `${analyticsActions.applyMoreFacetsModal}`,
443
443
  label: `${this.facetKey}`,
@@ -446,7 +446,7 @@ export class MoreFacetsContent extends LitElement {
446
446
 
447
447
  private cancelClick() {
448
448
  this.modalManager?.closeModal();
449
- this.analyticsHandler?.sendEventNoSampling({
449
+ this.analyticsHandler?.sendEvent({
450
450
  category: analyticsCategories.default,
451
451
  action: analyticsActions.closeMoreFacetsModal,
452
452
  label: `${this.facetKey}`,
@@ -466,7 +466,7 @@ export class CollectionFacets extends LitElement {
466
466
  class="more-link"
467
467
  @click=${() => {
468
468
  this.showMoreFacetsModal(facetGroup, 'count');
469
- this.analyticsHandler?.sendEventNoSampling({
469
+ this.analyticsHandler?.sendEvent({
470
470
  category: analyticsCategories.default,
471
471
  action: analyticsActions.showMoreFacetsModal,
472
472
  label: facetGroup.key,
@@ -9,6 +9,7 @@ import {
9
9
  import { ifDefined } from 'lit/directives/if-defined.js';
10
10
  import { join } from 'lit/directives/join.js';
11
11
  import { map } from 'lit/directives/map.js';
12
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
12
13
  import { customElement, property, state } from 'lit/decorators.js';
13
14
  import DOMPurify from 'dompurify';
14
15
 
@@ -295,7 +296,10 @@ export class TileList extends LitElement {
295
296
 
296
297
  private get descriptionTemplate() {
297
298
  return this.metadataTemplate(
298
- DOMPurify.sanitize(this.model?.description ?? ''),
299
+ // Sanitize away any HTML tags and convert line breaks to spaces.
300
+ unsafeHTML(
301
+ DOMPurify.sanitize(this.model?.description?.replace(/\n/g, ' ') ?? '')
302
+ ),
299
303
  '',
300
304
  'description'
301
305
  );
@@ -340,7 +344,7 @@ export class TileList extends LitElement {
340
344
  // Note: single ' for href='' to wrap " in query var gets changed back by yarn format
341
345
 
342
346
  // eslint-disable-next-line lit/no-invalid-html
343
- return html`<a href="${this.baseNavigationUrl}/search.php?query=${query}">
347
+ return html`<a href="${this.baseNavigationUrl}/search?query=${query}">
344
348
  ${DOMPurify.sanitize(searchTerm)}</a
345
349
  >`;
346
350
  }