/**
* @file This file instantiates the Typesense InstantSearch.js adapter and the InstantSearch.js search client.
* @author Kor Dwarshuis
* @version 1.0.0
* @since 2023-05-19
*/
import instantsearch from 'instantsearch.js/es';
// to be used in the future
// import { queriesWithSortAdjustment } from '/search-index-typesense/overrides/sortAdjustment.js';
import {
searchBox,
hits,
pagination,
// infiniteHits,
configure,
// stats,
// analytics,
refinementList,
clearRefinements,
// menu,
sortBy,
currentRefinements,
} from 'instantsearch.js/es/widgets';
import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter';
// import { SearchClient as TypesenseSearchClient } from 'typesense'; // To get the total number of docs
// import { connectSearchBox } from 'instantsearch.js/es/connectors'
import { connectRefinementList } from 'instantsearch.js/es/connectors';
// This is a custom widget that displays the refinement list for the "tag" attribute, but only for items that have the label "img". This is a replacement for the standard refinementList widget. The standard refinementList widget does not allow you to show a message when there are no items available. This custom widget does.
const refinementListImageToggle = connectRefinementList((renderOptions, isFirstRender) => {
const { items, widgetParams } = renderOptions;
const container = document.querySelector("#tag-refinement-list");
if (items.length === 0) {
// Display "No results" if there are no items
container.innerHTML = '<div class="mb-3">No images available</div>';
} else {
// Otherwise, build and display the refinement list
const list = items.map(item => {
return `<label>
<input type="checkbox" value="${item.value}" ${item.isRefined ? 'checked' : ''} />
${item.label} (${item.count})
</label>`;
}).join('');
container.innerHTML = `<div class="refinement-list">${list}</div>`;
}
});
const typeSenseInstantSearch = () => {
// "Try searching for:"
function handleSearchTermClick(event) {
const searchBox = document.querySelector('.ais-SearchBox-input');
search.helper.clearRefinements();
searchBox.value = event.currentTarget.textContent;
search.helper.setQuery(searchBox.value).search();
}
document.querySelectorAll('.clickable-search-term').forEach((el) => {
el.addEventListener('click', handleSearchTermClick);
});
// to be used in the future
// function applyCustomSorting(items) {
// console.log('items: ', items);
// const currentQuery = search.helper.state.query;
// const matchingQueryObj = queriesWithSortAdjustment.find(
// (obj) => obj.queryString === currentQuery
// );
// if (matchingQueryObj) {
// const sortAdjustment = matchingQueryObj.sortAdjustment;
// const urlSubstring = matchingQueryObj.urlSubstring;
// return items.map((item) => {
// item.sort_order = item.url && item.url.includes(urlSubstring) ? sortAdjustment : 0;
// return item;
// }).sort((a, b) => b.sort_order - a.sort_order);
// }
// return items;
// }
const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
server: {
apiKey: 'qy6mC9ZakKZ3C8GUD5T3iDrelDgpp5Zc', // Be sure to use an API key that only allows searches, in production
nodes: [
{
host: '9ktso7i1b8034azqp-1.a1.typesense.net',
port: '443',
protocol: 'https',
},
],
},
// The following parameters are directly passed to Typesense's search API endpoint.
// So you can pass any parameters supported by the search endpoint below.
// queryBy is required.
// filterBy is managed and overridden by InstantSearch.js. To set it, you want to use one of the filter widgets like refinementList or use the `configure` widget.
additionalSearchParameters: {
// query_by: 'title,authors',
// query_by: 'imgMeta, content, firstHeadingBeforeElement, pageTitle, siteName, source, url',
query_by: 'content, firstHeadingBeforeElement, pageTitle, siteName, source, url',
// weights: '10000,1,1,1,1,1,1',
// filter_by: 'tag:=[p]',
// filter_by: 'tag:[a]',
// filter_by: 'contentLength:>50',
// sort_by: 'contentLength:asc',//asc or desc
// sort_by: 'imgMetaLength:asc, contentLength:asc',//asc or desc
sort_by: 'imgWidth:desc,contentLength:desc,imgUrl(missing_values: last):desc',//asc or desc
// sort_by: 'imgWidth:desc,imgUrl(missing_values: last):desc',//asc or desc
group_by: 'url',
group_limit: 1
},
});
const searchClient = typesenseInstantsearchAdapter.searchClient;
const search = instantsearch({
searchClient,
indexName: 'Wot-terms',// production
// indexName: 'Wot-terms-test',// testing
routing: true,
// searchFunction(helper) {
// if (helper.state.query === '') {
// document
// .querySelector('.search-modal-backdrop')
// .classList.add('hidden');
// document.querySelector('#search').classList.add('hidden');
// } else {
// document
// .querySelector('.search-modal-backdrop')
// .classList.remove('hidden');
// document.querySelector('#search').classList.remove('hidden');
// }
// helper.search();
// },
});
search.addWidgets([
searchBox({
container: '#search-box',
showSubmit: false,
showReset: false,
showLoadingIndicator: true,
placeholder: 'Enter Search…',
autofocus: true,
cssClasses: {
input: 'form-control',
},
// queryHook(query, search) {
// const modifiedQuery = queryWithoutStopWords(query);
// if (modifiedQuery.trim() !== '') {
// search(modifiedQuery);
// }
// },
}),
configure({
hitsPerPage: 10,
}),
hits({
container: '#hits',
// to be used in the future
// transformItems(items) {
// let sortedItems = applyCustomSorting(items);
// return sortedItems;
// },
templates: {
item(item) {
function makeCodeStringShorter(string) {
/*
Sometimes code blocks are very long and they take up a lot of space in the search results.
This function takes a string as input and returns a modified version of the string.
It finds the first occurrence of the <marker> tag and the </marker> tag in the input string.
Then, it extracts a substring that includes 100 characters before the first <marker> and 100 characters after the first </marker>.
The extracted substring is wrapped in an HTML <span> element with the class "highlighted".
If either the <marker> tag or the </marker> tag is not found, the function returns the original string unchanged.
*/
// Find the index of the first occurrence of <marker> and </marker> in the string
let firstMarkerTagIndex = string.indexOf('<mark>');
let lastMarkerTagIndex = string.indexOf('</mark>');
// Check if either < marker > or </ > is not found in the string
if (firstMarkerTagIndex === -1 || lastMarkerTagIndex === -1) {
return string; // Return the original string if the tags are not found
}
// Calculate the start and end indices for the substring
let start = Math.max(0, firstMarkerTagIndex - 300); // Start xxx characters before the first <marker> or at the beginning of the string
let end = Math.min(string.length, lastMarkerTagIndex + 300); // End xxx characters after the first </marker> or at the end of the string
// Extract the substring containing 100 characters before the first <marker> and 100 characters after the first </marker>
let firstMarkerTagWith100CharactersBeforeAndAfterIt = string.substring(start, end);
// Add a span with the "highlighted" class around the extracted substring
firstMarkerTagWith100CharactersBeforeAndAfterIt = `<span class="shorter-code">${firstMarkerTagWith100CharactersBeforeAndAfterIt}</span>`;
return firstMarkerTagWith100CharactersBeforeAndAfterIt; // Return the modified string
}
// External links should open in a new tab
let openInNewTab = '';
if (item.url.indexOf('weboftrust.github.io/WOT-terms') === -1) {
openInNewTab = 'target="_blank" rel="noopener"';
}
// "Postprocess" the content. Especially code samples can be very long and take up a lot of space in the search results. This function makes the code samples shorter. TODO: check if other content types need to be shortened as well.
let postProcessedCode = '';
// If the tag is pre or textarea, wrap the content in a <pre> tag, first let's do the opening tag
let postProcessedOpeningTag = '';
if (item.tag === 'pre' || item.tag === 'textarea') {
postProcessedOpeningTag = '<pre>';
postProcessedCode = makeCodeStringShorter(item._highlightResult.content.value);
} else { // Otherwise, wrap the content in a <p> tag
postProcessedOpeningTag = '<p class="ms-5">'
postProcessedCode = item._highlightResult.content.value;
}
// If the tag is pre or textarea, wrap the content in a <pre> tag, now let's do the closing tag
let postProcessedClosingTag = '';
if (item.tag === 'pre' || item.tag === 'textarea') {
postProcessedClosingTag = '</pre>';
} else { // Otherwise, wrap the content in a <p> tag
postProcessedClosingTag = '</p>'
}
// END "Postprocess" the content
// Only if curated is true, show a sticky label
let itemCurated = item.curated === true ? `<p role="alert" class='alert alert-info text-center p-1 d-inline fs-6'><small class="">Sticky</small></p>` : '';
// Only if siteName is not empty, show it
let itemSiteNameTemplateString = item.siteName !== '' ? `${item._highlightResult.siteName.value}` : '';
// Only if title is not empty, show it
// mb-4
let itemTitleTemplateString = item.pageTitle !== '' ? `<h3 class="page-title mb-2 ms-4">${item._highlightResult.pageTitle.value}</h3>` : '';
// Only if author is not empty, show it
let itemAuthorTemplateString = item.author !== '' ? `• ${item._highlightResult.author.value}` : '';
// Add class to img based on imgWidth (img that are under 301 are assumed to be logos etc, above 301 are assumed to be explanations, flowcharts, etc)
let imgClass = '';
item.imgWidth < 301 ? imgClass = "inline-thumb-start" : imgClass = "";
// Only if imgUrl is not empty, show it
let itemImgUrlTemplateString = item.imgUrl !== '' ? `<img class="search-results-img ${imgClass}" src='${item.imgUrl}'>` : '';
// Only if imgMeta is not empty, show it
let itemImgMetaTemplateString = item.imgMeta !== '' ? `<p class="ms-5 mt-5">${item._highlightResult.imgMeta.value}</p>` : '';
// Only if creationDate is not empty, show it
let itemCreationDateTemplateString = item.creationDate !== '' ? `• ${item.creationDate}` : '';
// Only if knowledgeLevel is not empty, show it
let itemKnowledgeLevelTemplateString = item.knowledgeLevel !== '' ? `• Level: ${item.knowledgeLevel}` : '';
// Only if type is not empty, show it
let itemTypeTemplateString = item.type !== '' ? `• ${item.type}` : '';
// Only if hierarchy.lvl1 is not empty, show it
let itemHierarchyLvl1TemplateString = item['hierarchy.lvl1'] !== '' ? `• ${item['hierarchy.lvl1']}` : '';
// Only if firstHeadingBeforeElement is not empty, show it
let itemFirstHeadingBeforeElementTemplateString = item.firstHeadingBeforeElement !== '' ? `<h4 class="first-heading-before-element ms-5">${item.firstHeadingBeforeElement}</h4>` : '';
let siteBrandingClass = '';
if (item.siteName === "Gleif website") {
siteBrandingClass = "gleif";
}
if (item.siteName === "eSSIF-Lab") {
siteBrandingClass = "essif-lab";
}
if (item.siteName === "KERISSE (this site)") {
siteBrandingClass = "kerisse";
}
return `
<div class="card border-secondary mt-5 scroll-shadows" data-typesense-id="${item.id}">
<div class="card-header ${siteBrandingClass}">
${itemCurated}<p class="d-inline"> Found on: ${itemSiteNameTemplateString}</p>
</div>
<div class="card-body text-secondary">
<div style="font-size: 0.9rem;">
<a class="search-hit-url btn btn-outline-primary mb-2" href="${item.url}" ${openInNewTab}>${item._highlightResult.url.value}</a>
${itemAuthorTemplateString}
${itemCreationDateTemplateString}
${itemKnowledgeLevelTemplateString}
${itemTypeTemplateString}
${itemHierarchyLvl1TemplateString}
</div>
<hr>
${itemTitleTemplateString}
${itemFirstHeadingBeforeElementTemplateString}
${postProcessedOpeningTag}
${postProcessedCode}
${postProcessedClosingTag}
${itemImgUrlTemplateString}
${itemImgMetaTemplateString}
</div>
<div class="card-footer p-3">
<a href="#search-close" class="btn btn-outline-secondary d-inline btn-sm align-self-start p-2">back to top</a>
<a class="btn btn-outline-primary d-inline btn-sm align-self-start p-2" href="${item.url}">to URL</a>
<button type="button" class="btn btn-outline-secondary d-inline align-self-end p-1 upvote">upvote ↑</button>
</div>
</div>
`;
},
},
}),
pagination({
container: '#pagination',
}),
clearRefinements({
container: '#clear-refinements',
templates: {
resetLabel: 'Clear filters'
},
cssClasses: {
button: 'btn btn-secondary btn-sm align-content-center mb-5 mt-3'
}
}),
currentRefinements({
container: '#current-refinements-list',
cssClasses: {
list: 'list-unstyled',
item: '',
delete: 'btn btn-sm btn-link text-decoration-none p-0 px-2',
},
transformItems: (items) => {
// hide the heading if there are no current refinements
document.querySelector("#current-refinements-list-container").classList.add("d-none");
const labelLookup = {
content: 'Content',
author: 'Author',
category: 'Category',
source: 'Source',
mediaType: 'File type',
};
const modifiedItems = items.map((item) => {
// show the heading if there are current refinements
document.querySelector("#current-refinements-list-container").classList.remove("d-none");
return {
...item,
label: labelLookup[item.attribute] || '',
};
});
return modifiedItems;
},
}),
// Currently not useful
// sortBy({
// container: '#sort-by',
// items: [
// { label: 'Default Sort', value: 'Wot-terms' },
// { label: 'Content Length: Low to High', value: 'Wot-terms/sort/contentLength:asc' },
// { label: 'Content Length: High to Low', value: 'Wot-terms/sort/contentLength:desc' },
// ],
// cssClasses: {
// select: 'form-select form-select-sm mb-2 border-light-2',
// },
// }),
// // KNOWLEDGELEVEL
// refinementList({
// container: '#knowledgelevel-refinement-list',
// attribute: 'knowledgeLevel',
// searchable: false,
// searchablePlaceholder: 'Search knowledge level',
// showMore: false,
// cssClasses: {
// searchableInput: 'form-control form-control-sm mb-2 border-light-2',
// searchableSubmit: 'hidden',
// searchableReset: 'hidden',
// showMore: 'btn btn-secondary btn-sm align-content-center',
// list: 'list-unstyled',
// count: '',
// label: '',
// checkbox: 'me-2',
// },
// sortBy: ['name:asc', 'count:desc'],
// }),
// // TYPE
// refinementList({
// container: '#type-refinement-list',
// attribute: 'type',
// searchable: false,
// searchablePlaceholder: 'Search type',
// showMore: false,
// cssClasses: {
// searchableInput: 'form-control form-control-sm mb-2 border-light-2',
// searchableSubmit: 'hidden',
// searchableReset: 'hidden',
// showMore: 'btn btn-secondary btn-sm align-content-center',
// list: 'list-unstyled',
// count: '',
// label: '',
// checkbox: 'me-2',
// },
// sortBy: ['name:asc', 'count:desc'],
// }),
// // SUBJECT
// refinementList({
// container: '#subject-refinement-list',
// attribute: 'hierarchy.lvl1',
// searchable: false,
// searchablePlaceholder: 'Subject',
// showMore: false,
// cssClasses: {
// searchableInput: 'form-control form-control-sm mb-2 border-light-2',
// searchableSubmit: 'hidden',
// searchableReset: 'hidden',
// showMore: 'btn btn-secondary btn-sm align-content-center',
// list: 'list-unstyled',
// count: '',
// label: '',
// checkbox: 'me-2',
// },
// sortBy: ['name:asc', 'count:desc'],
// }),
// TAG
refinementListImageToggle({
container: '#tag-refinement-list',
attribute: 'tag',
// Include other necessary widget options here
transformItems: items => items.filter(item => ['img'].includes(item.label)),
limit: 1000
}),
// CATEGORY
refinementList({
container: '#category-refinement-list',
attribute: 'category',
searchable: true,
searchablePlaceholder: 'Category',
showMore: false,
// max_facet_values: 100, TODO: does this work?
cssClasses: {
searchableInput: 'form-control form-control-sm mb-2 border-light-2',
searchableSubmit: 'hidden',
searchableReset: 'hidden',
showMore: 'btn btn-secondary btn-sm align-content-center',
list: 'list-unstyled',
count: '',
label: '',
checkbox: 'me-2',
},
sortBy: ['name:asc', 'count:desc'],
}),
// SOURCE
refinementList({
container: '#source-refinement-list',
attribute: 'source',
searchable: true,
searchablePlaceholder: 'Source',
showMore: true,
// max_facet_values: 100, TODO: does this work?
cssClasses: {
searchableInput: 'form-control form-control-sm mb-2 border-light-2',
searchableSubmit: 'hidden',
searchableReset: 'hidden',
showMore: 'btn btn-secondary btn-sm align-content-center',
list: 'list-unstyled',
count: '',
label: '',
checkbox: 'me-2',
},
sortBy: ['name:asc', 'count:desc'],
}),
refinementList({
container: '#author-refinement-list',
attribute: 'author',
searchable: true,
searchablePlaceholder: 'Author',
showMore: true,
// max_facet_values: 100,TODO: does this work?
cssClasses: {
searchableInput: 'form-control form-control-sm mb-2 border-light-2',
searchableSubmit: 'hidden',
searchableReset: 'hidden',
showMore: 'btn btn-secondary btn-sm align-content-center',
list: 'list-unstyled',
count: '',
label: '',
checkbox: 'me-2',
},
sortBy: ['name:asc', 'count:desc'],
}),
// MEDIATYPE
refinementList({
container: '#media-type-refinement-list',
attribute: 'mediaType',
searchable: true,
searchablePlaceholder: 'File type',
showMore: true,
// max_facet_values: 100,TODO: does this work?
cssClasses: {
searchableInput: 'form-control form-control-sm mb-2 border-light-2',
searchableSubmit: 'hidden',
searchableReset: 'hidden',
showMore: 'btn btn-secondary btn-sm align-content-center',
list: 'list-unstyled',
count: '',
label: '',
checkbox: 'me-2',
},
sortBy: ['name:asc', 'count:desc'],
}),
]);
// function handleSearchTermClick(event) {
// const searchBox = document.querySelector('#search-box input[type=search]');
// search.helper.clearRefinements();
// searchBox.val(event.currentTarget.textContent);
// search.helper.setQuery(searchBox.val()).search();
// }
// search.on('render', function () {
// // Make artist names clickable
// // $('#hits .clickable-search-term').on('click', handleSearchTermClick);
// document.querySelectorAll('.hit-url a').forEach((el) => {
// el.addEventListener('click', handleSearchTermClick);
// });
// });
search.start();
};
export function onRouteDidUpdate({ location, previousLocation }) {
// Don't execute if we are still on the same page; the lifecycle may be fired
// because the hash changes (e.g. when navigating between headings)
if (location.pathname === previousLocation?.pathname) return;
typeSenseInstantSearch();
}