diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c8c608c6f..dc38af551 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,12 +14,14 @@ "chartjs-plugin-annotation": "^0.5.7", "chartjs-plugin-trendline": "^0.2.2", "crypto-browserify": "^3.12.0", + "damerau-levenshtein": "1.0.8", "dom-to-image": "^2.6.0", "firebase": "^8.4.2", "gulp-replace": "^1.1.3", "howler": "^2.2.1", "moment-timezone": "^0.5.33", "node-object-hash": "2.3.10", + "stemmer": "2.0.0", "tinycolor2": "^1.4.2" }, "devDependencies": { @@ -27,6 +29,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.16.8", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-env": "^7.16.11", + "@types/damerau-levenshtein": "1.0.0", "@types/grecaptcha": "^3.0.3", "@types/howler": "^2.2.5", "@types/jquery": "^3.5.13", @@ -2236,6 +2239,12 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" }, + "node_modules/@types/damerau-levenshtein": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/damerau-levenshtein/-/damerau-levenshtein-1.0.0.tgz", + "integrity": "sha512-8XQ1jJHlOl6HjZ3/fU9Yrm/14jxM4gXVezPWiwkyiG0GnYROsI6wdh8DwKccAFGDNiNYBooTZkRXVe4du6plKA==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -4058,6 +4067,11 @@ "type": "^1.0.1" } }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" + }, "node_modules/dart-sass": { "version": "1.25.0", "resolved": "https://registry.npmjs.org/dart-sass/-/dart-sass-1.25.0.tgz", @@ -10652,6 +10666,18 @@ "node": ">=0.10.0" } }, + "node_modules/stemmer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stemmer/-/stemmer-2.0.0.tgz", + "integrity": "sha512-0YS2oMdTZ/wAWUHMMpf7AAJ8Gm6dHXyHddJ0zCu2DIfOfIbdwqAm1bbk4+Vti6gxNIcOrnm5jAP7vYTzQDvc5A==", + "bin": { + "stemmer": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -13742,6 +13768,12 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" }, + "@types/damerau-levenshtein": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/damerau-levenshtein/-/damerau-levenshtein-1.0.0.tgz", + "integrity": "sha512-8XQ1jJHlOl6HjZ3/fU9Yrm/14jxM4gXVezPWiwkyiG0GnYROsI6wdh8DwKccAFGDNiNYBooTZkRXVe4du6plKA==", + "dev": true + }, "@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -15235,6 +15267,11 @@ "type": "^1.0.1" } }, + "damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" + }, "dart-sass": { "version": "1.25.0", "resolved": "https://registry.npmjs.org/dart-sass/-/dart-sass-1.25.0.tgz", @@ -20387,6 +20424,11 @@ } } }, + "stemmer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stemmer/-/stemmer-2.0.0.tgz", + "integrity": "sha512-0YS2oMdTZ/wAWUHMMpf7AAJ8Gm6dHXyHddJ0zCu2DIfOfIbdwqAm1bbk4+Vti6gxNIcOrnm5jAP7vYTzQDvc5A==" + }, "stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d98a69891..39cf9a490 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.16.8", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-env": "^7.16.11", + "@types/damerau-levenshtein": "1.0.0", "@types/grecaptcha": "^3.0.3", "@types/howler": "^2.2.5", "@types/jquery": "^3.5.13", @@ -56,12 +57,14 @@ "chartjs-plugin-annotation": "^0.5.7", "chartjs-plugin-trendline": "^0.2.2", "crypto-browserify": "^3.12.0", + "damerau-levenshtein": "1.0.8", "dom-to-image": "^2.6.0", "firebase": "^8.4.2", "gulp-replace": "^1.1.3", "howler": "^2.2.1", "moment-timezone": "^0.5.33", "node-object-hash": "2.3.10", + "stemmer": "2.0.0", "tinycolor2": "^1.4.2" } } diff --git a/frontend/src/scripts/popups/custom-text-popup.ts b/frontend/src/scripts/popups/custom-text-popup.ts index 29b8bca02..24b313309 100644 --- a/frontend/src/scripts/popups/custom-text-popup.ts +++ b/frontend/src/scripts/popups/custom-text-popup.ts @@ -6,6 +6,7 @@ import Config, * as UpdateConfig from "../config"; import * as Misc from "../misc"; import * as WordFilterPopup from "./word-filter-popup"; import * as Notifications from "../elements/notifications"; +import * as SavedTextsPopup from "./saved-texts-popup"; const wrapper = "#customTextPopupWrapper"; const popup = "#customTextPopup"; @@ -107,8 +108,16 @@ $(`${popup} .randomInputFields .time input`).keypress(() => { $(`${popup} .randomInputFields .wordcount input`).val(""); }); -$("#customTextPopup .apply").click(() => { - let text = ($("#customTextPopup textarea").val() as string).normalize(); +$(`${popup} .buttonsTop .showSavedTexts`).on("click", () => { + SavedTextsPopup.show(); +}); + +$(`${popup} .buttonsTop .saveCustomText`).on("click", () => { + hide(); +}); + +function apply(): void { + let text = ($(`${popup} textarea`).val() as string).normalize(); text = text.trim(); // text = text.replace(/[\r]/gm, " "); text = text.replace(/\\\\t/gm, "\t"); @@ -120,26 +129,20 @@ $("#customTextPopup .apply").click(() => { // text = text.replace(/(\n)+/g, "\n"); // text = text.replace(/(\r)+/g, "\r"); text = text.replace(/( *(\r\n|\r|\n) *)/g, "\n "); - if ($("#customTextPopup .typographyCheck input").prop("checked")) { + if ($(`${popup} .typographyCheck input`).prop("checked")) { text = Misc.cleanTypographySymbols(text); } // text = Misc.remove_non_ascii(text); text = text.replace(/[\u2060]/g, ""); CustomText.setText(text.split(CustomText.delimiter)); - CustomText.setWord( - parseInt($("#customTextPopup .wordcount input").val() as string) - ); - CustomText.setTime( - parseInt($("#customTextPopup .time input").val() as string) - ); + CustomText.setWord(parseInt($(`${popup} .wordcount input`).val() as string)); + CustomText.setTime(parseInt($(`${popup} .time input`).val() as string)); CustomText.setIsWordRandom( - $("#customTextPopup .checkbox input").prop("checked") && - !isNaN(CustomText.word) + $(`${popup} .checkbox input`).prop("checked") && !isNaN(CustomText.word) ); CustomText.setIsTimeRandom( - $("#customTextPopup .checkbox input").prop("checked") && - !isNaN(CustomText.time) + $(`${popup} .checkbox input`).prop("checked") && !isNaN(CustomText.time) ); if ( @@ -184,9 +187,13 @@ $("#customTextPopup .apply").click(() => { if (Config.mode !== "custom") UpdateConfig.setMode("custom"); TestLogic.restart(); hide(); +} + +$(document).on("click", `${popup} .button.apply`, () => { + apply(); }); -$("#customTextPopup .wordfilter").click(() => { +$(document).on("click", `${popup} .wordfilter`, () => { WordFilterPopup.show(); }); diff --git a/frontend/src/scripts/popups/quote-search-popup.ts b/frontend/src/scripts/popups/quote-search-popup.ts index 830f77fdb..ed98c73ae 100644 --- a/frontend/src/scripts/popups/quote-search-popup.ts +++ b/frontend/src/scripts/popups/quote-search-popup.ts @@ -7,6 +7,12 @@ import * as QuoteSubmitPopup from "./quote-submit-popup"; import * as QuoteApprovePopup from "./quote-approve-popup"; import * as QuoteReportPopup from "./quote-report-popup"; import * as Misc from "../misc"; +import { + buildSearchService, + SearchService, + TextExtractor, +} from "../utils/search-service"; +import { debounce } from "../utils/debounce"; export let selectedId = 1; @@ -14,39 +20,65 @@ export function setSelectedId(val: number): void { selectedId = val; } +const searchServiceCache: Record> = {}; + +function getSearchService( + language: string, + data: T[], + textExtractor: TextExtractor +): SearchService { + if (language in searchServiceCache) { + return searchServiceCache[language]; + } + + const newSearchService = buildSearchService(data, textExtractor); + searchServiceCache[language] = newSearchService; + + return newSearchService; +} + +function highlightMatches(text: string, matchedText: string[]): string { + if (matchedText.length === 0) { + return text; + } + const words = text.split( + /(?=[.,'"/#!$%^&*;:{}=\-_`~()\s])|(?<=[.,'"/#!$%^&*;:{}=\-_`~()\s])/g + ); + + const normalizedWords = words.map((word) => { + const shouldHighlight = matchedText.find((match) => { + return word.startsWith(match); + }); + return shouldHighlight ? `${word}` : word; + }); + + return normalizedWords.join(""); +} + async function updateResults(searchText: string): Promise { - const quotes = await Misc.getQuotes(Config.language); - const reg = new RegExp(searchText, "i"); - const found: MonkeyTypes.Quote[] = []; - quotes.quotes.forEach((quote) => { - const quoteText = quote["text"].replace(/[.,'"/#!$%^&*;:{}=\-_`~()]/g, ""); - const test1 = reg.test(quoteText); - if (test1) { - found.push(quote); + const { quotes } = await Misc.getQuotes(Config.language); + + const quoteSearchService = getSearchService( + Config.language, + quotes, + (quote: MonkeyTypes.Quote) => { + return `${quote.text} ${quote.id} ${quote.source}`; } - }); - quotes.quotes.forEach((quote) => { - const quoteSource = quote["source"].replace( - /[.,'"/#!$%^&*;:{}=\-_`~()]/g, - "" - ); - const quoteId = quote["id"]; - const test2 = reg.test(quoteSource); - const test3 = reg.test(quoteId.toString()); - if ((test2 || test3) && found.filter((q) => q.id == quote.id).length == 0) { - found.push(quote); - } - }); + ); + const { results: matches, matchedQueryTerms } = + quoteSearchService.query(searchText); + $("#quoteSearchResults").remove(); $("#quoteSearchPopup").append( '
' ); - const resultsList = $("#quoteSearchResults"); - let resultListLength = 0; + const resultsList = $("#quoteSearchResults"); const isNotAuthed = !firebase.auth().currentUser; - found.forEach(async (quote) => { + const quotesToShow = searchText === "" ? quotes : matches; + + quotesToShow.slice(0, 100).forEach((quote) => { let lengthDesc; if (quote.length < 101) { lengthDesc = "short"; @@ -57,15 +89,21 @@ async function updateResults(searchText: string): Promise { } else { lengthDesc = "thicc"; } - if (resultListLength++ < 100) { - resultsList.append(` + resultsList.append(`
-
${quote.text}
-
id
${ - quote.id - }
+
${highlightMatches( + quote.text, + matchedQueryTerms + )}
+
id
${highlightMatches( + quote.id.toString(), + matchedQueryTerms + )}
length
${lengthDesc}
-
source
${quote.source}
+
source
${highlightMatches( + quote.source, + matchedQueryTerms + )}
@@ -73,15 +111,14 @@ async function updateResults(searchText: string): Promise {
`); - } }); - if (found.length > 100) { + if (quotesToShow.length > 100) { $("#extraResults").html( - found.length + + quotesToShow.length + " results (only showing 100)" ); } else { - $("#extraResults").html(found.length + " results"); + $("#extraResults").html(quotesToShow.length + " results"); } } @@ -158,17 +195,14 @@ export function apply(val: number): boolean { return ret; } -$("#quoteSearchPopup .searchBox").keydown((e) => { - if (e.code == "Escape") return; - setTimeout(() => { - let searchText = (document.getElementById("searchBox")) - .value; - searchText = searchText - .replace(/[.,'"/#!$%^&*;:{}=\-_`~()]/g, "") - .replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +const debouncedSearch = debounce(updateResults); - updateResults(searchText); - }, 0.1); //arbitrarily v. small time as it's only to allow text to input before searching +$("#quoteSearchPopup .searchBox").on("keyup", (e) => { + if (e.code === "Escape") return; + + const searchText = (document.getElementById("searchBox")) + .value; + debouncedSearch(searchText); }); $("#quoteSearchPopupWrapper").click((e) => { @@ -218,17 +252,3 @@ $(document).keydown((event) => { event.preventDefault(); } }); - -// $("#quoteSearchPopup input").keypress((e) => { -// if (e.keyCode == 13) { -// if (!isNaN(document.getElementById("searchBox").value)) { -// apply(); -// } else { -// let results = document.getElementsByClassName("searchResult"); -// if (results.length > 0) { -// selectedId = parseInt(results[0].getAttribute("id")); -// apply(selectedId); -// } -// } -// } -// }); diff --git a/frontend/src/scripts/popups/saved-texts-popup.ts b/frontend/src/scripts/popups/saved-texts-popup.ts new file mode 100644 index 000000000..b53715f67 --- /dev/null +++ b/frontend/src/scripts/popups/saved-texts-popup.ts @@ -0,0 +1,56 @@ +import * as CustomText from "../test/custom-text"; + +export async function show(): Promise { + const names = CustomText.getCustomTextNames(); + const listEl = $(`#savedTextsPopup .list`).empty(); + let list = ""; + if (names.length === 0) { + list += "
No saved custom texts found
"; + } else { + for (const name of names) { + list += `
+
${name}
+
+ +
+
`; + } + } + listEl.html(list); + $("#savedTextsPopupWrapper").removeClass("hidden"); + $("#customTextPopupWrapper").addClass("hidden"); +} + +function hide(full = false): void { + $("#savedTextsPopupWrapper").addClass("hidden"); + if (!full) $("#customTextPopupWrapper").removeClass("hidden"); +} + +function applySaved(name: string): void { + const text = CustomText.getCustomText(name); + $(`#customTextPopupWrapper textarea`).val(text.join(CustomText.delimiter)); +} + +$(document).on( + "click", + `#savedTextsPopupWrapper .list .savedText .button.name`, + (e) => { + const name = $(e.target).text(); + applySaved(name); + hide(); + } +); + +$(document).on( + "click", + `#savedTextsPopupWrapper .list .savedText .button.delete`, + () => { + hide(true); + } +); + +$("#savedTextsPopupWrapper").mousedown((e) => { + if ($(e.target).attr("id") === "savedTextsPopupWrapper") { + hide(); + } +}); diff --git a/frontend/src/scripts/popups/simple-popups.ts b/frontend/src/scripts/popups/simple-popups.ts index 10729e76f..7f4310497 100644 --- a/frontend/src/scripts/popups/simple-popups.ts +++ b/frontend/src/scripts/popups/simple-popups.ts @@ -7,6 +7,9 @@ import * as Loader from "../elements/loader"; import * as Notifications from "../elements/notifications"; import * as Settings from "../pages/settings"; import * as ApeKeysPopup from "../popups/ape-keys-popup"; +import * as CustomText from "../test/custom-text"; +import * as CustomTextPopup from "../popups/custom-text-popup"; +import * as SavedTextsPopup from "./saved-texts-popup"; type Input = { placeholder: string; @@ -885,6 +888,52 @@ list["editApeKey"] = new SimplePopup( } ); +list["saveCustomText"] = new SimplePopup( + "saveCustomText", + "text", + "Save custom text", + [ + { + placeholder: "Name", + initVal: "", + }, + ], + "", + "Save", + (_thisPopup, input) => { + const text = ($(`#customTextPopup textarea`).val() as string).normalize(); + CustomText.setCustomText(input, text); + Notifications.add("Custom text saved", 1); + CustomTextPopup.show(); + }, + () => { + // + }, + () => { + // + } +); + +list["deleteCustomText"] = new SimplePopup( + "deleteCustomText", + "text", + "Delete custom text", + [], + "Are you sure?", + "Delete", + (_thisPopup) => { + CustomText.deleteCustomText(_thisPopup.parameters[0]); + Notifications.add("Custom text deleted", 1); + SavedTextsPopup.show(); + }, + (_thisPopup) => { + _thisPopup.text = `Are you sure you want to delete custom text ${_thisPopup.parameters[0]}?`; + }, + () => { + // + } +); + $(".pageSettings .section.discordIntegration #unlinkDiscordButton").click( () => { list["unlinkDiscord"].show(); @@ -923,6 +972,19 @@ $("#apeKeysPopup .generateApeKey").on("click", () => { list["generateApeKey"].show(); }); +$(`#customTextPopup .buttonsTop .saveCustomText`).on("click", () => { + list["saveCustomText"].show(); +}); + +$(document).on( + "click", + `#savedTextsPopupWrapper .list .savedText .button.delete`, + (e) => { + const name = $(e.target).siblings(".button.name").text(); + list["deleteCustomText"].show([name]); + } +); + $(document).on("click", "#apeKeysPopup table tbody tr .button.delete", (e) => { const keyId = $(e.target).closest("tr").attr("keyId") as string; list["deleteApeKey"].show([keyId]); diff --git a/frontend/src/scripts/test/custom-text.ts b/frontend/src/scripts/test/custom-text.ts index 45c36232d..72d3d05c5 100644 --- a/frontend/src/scripts/test/custom-text.ts +++ b/frontend/src/scripts/test/custom-text.ts @@ -1,4 +1,14 @@ -export let text = "The quick brown fox jumps over the lazy dog".split(" "); +export let text = [ + "The", + "quick", + "brown", + "fox", + "jumps", + "over", + "the", + "lazy", + "dog", +]; export let isWordRandom = false; export let isTimeRandom = false; export let word: number; @@ -28,3 +38,36 @@ export function setWord(val: number): void { export function setDelimiter(val: string): void { delimiter = val; } + +type CustomTextObject = { [key: string]: string }; + +export function getCustomText(name: string): string[] { + const customText = getCustomTextObject(); + + return customText[name].split(/ +/); +} + +export function setCustomText(name: string, text: string | string[]): void { + const customText = getCustomTextObject(); + + if (typeof text === "string") customText[name] = text; + else customText[name] = text.join(" "); + + window.localStorage.setItem("customText", JSON.stringify(customText)); +} + +export function deleteCustomText(name: string): void { + const customText = getCustomTextObject(); + + if (customText[name]) delete customText[name]; + + window.localStorage.setItem("customText", JSON.stringify(customText)); +} + +function getCustomTextObject(): CustomTextObject { + return JSON.parse(window.localStorage.getItem("customText") ?? "{}"); +} + +export function getCustomTextNames(): string[] { + return Object.keys(getCustomTextObject()); +} diff --git a/frontend/src/scripts/utils/debounce.ts b/frontend/src/scripts/utils/debounce.ts new file mode 100644 index 000000000..8412cb403 --- /dev/null +++ b/frontend/src/scripts/utils/debounce.ts @@ -0,0 +1,8 @@ +export function debounce(fn: any, ms = 250): any { + let timeoutId: ReturnType; + + return function (this: any, ...args: any[]) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn.apply(this, args), ms); + }; +} diff --git a/frontend/src/scripts/utils/search-service.ts b/frontend/src/scripts/utils/search-service.ts new file mode 100644 index 000000000..83218396f --- /dev/null +++ b/frontend/src/scripts/utils/search-service.ts @@ -0,0 +1,155 @@ +import { stemmer } from "stemmer"; +import levenshtein from "damerau-levenshtein"; + +export interface SearchService { + query: (query: string) => SearchResult; +} + +interface SearchServiceOptions { + fuzzyMatchSensitivity: number; + scoreForSimilarMatch: number; + scoreForExactMatch: number; +} + +interface InternalDocument { + id: number; +} + +interface ReverseIndex { + [key: string]: Set; +} + +interface TokenMap { + [key: string]: Set; +} + +interface SearchResult { + results: T[]; + matchedQueryTerms: string[]; +} + +export type TextExtractor = (document: T) => string; + +const DEFAULT_OPTIONS: SearchServiceOptions = { + fuzzyMatchSensitivity: 0.2, // Value between 0-1. Higher = more tolerant to spelling mistakes, too high and you get nonsense. + scoreForSimilarMatch: 0.5, // When ranking results, the score a match gets for having a token that is similar to a search token. + scoreForExactMatch: 1, // When ranking results, the score a match gets for having an exact match with a token in the search query. +}; + +function inverseDocumentFrequency( + numberOfDocuments: number, + numberOfDocumentsWithTerm: number +): number { + if (numberOfDocumentsWithTerm === 0) { + return 0; + } + + return Math.log10(numberOfDocuments / numberOfDocumentsWithTerm); +} + +function tokenize(text: string): string[] { + return text.match(/[a-zA-Z0-9]+/g) || []; +} + +export const buildSearchService = ( + documents: T[], + getSearchableText: TextExtractor, + options: SearchServiceOptions = DEFAULT_OPTIONS +): SearchService => { + const reverseIndex: ReverseIndex = {}; + const normalizedTokenToOriginal: TokenMap = {}; + + documents.forEach((document, documentIndex) => { + const rawTokens = tokenize(getSearchableText(document)); + + const internalDocument: InternalDocument = { + id: documentIndex, + }; + + rawTokens.forEach((token) => { + const stemmedToken = stemmer(token); + + if (!(stemmedToken in normalizedTokenToOriginal)) { + normalizedTokenToOriginal[stemmedToken] = new Set(); + } + normalizedTokenToOriginal[stemmedToken].add(token); + + if (!(stemmedToken in reverseIndex)) { + reverseIndex[stemmedToken] = new Set(); + } + reverseIndex[stemmedToken].add(internalDocument); + }); + }); + + const tokenSet = Object.keys(reverseIndex); + + const query = (searchQuery: string): SearchResult => { + const searchResult: SearchResult = { + results: [], + matchedQueryTerms: [], + }; + + const normalizedSearchQuery = new Set( + tokenize(searchQuery).map((token) => stemmer(token)) + ); + if (normalizedSearchQuery.size === 0) { + return searchResult; + } + + const results = new Map(); + const matchedTokens = new Set(); + + normalizedSearchQuery.forEach((searchToken) => { + tokenSet.forEach((token) => { + const { similarity } = levenshtein(searchToken, token); + + const matchesSearchToken = token === searchToken; + const isSimilar = similarity >= 1 - options.fuzzyMatchSensitivity; + + if (matchesSearchToken || isSimilar) { + const documentMatches = reverseIndex[token]; + + const idf = inverseDocumentFrequency( + documents.length, + documentMatches.size + ); + + documentMatches.forEach((document) => { + const currentScore = results.get(document.id) ?? 0; + + const scoreForExactMatch = matchesSearchToken + ? options.scoreForExactMatch + : 0; + const scoreForSimilarity = isSimilar + ? options.scoreForSimilarMatch + : 0; + const score = scoreForExactMatch + scoreForSimilarity; + + const scoreForToken = score * idf; + + results.set(document.id, currentScore + scoreForToken); + }); + + normalizedTokenToOriginal[token].forEach((originalToken) => { + matchedTokens.add(originalToken); + }); + } + }); + }); + + const orderedResults = [...results] + .sort((match1, match2) => { + return match2[1] - match1[1]; + }) + .map((match) => documents[match[0]]); + + searchResult.results = orderedResults; + searchResult.matchedQueryTerms = [...matchedTokens]; + + return searchResult; + }; + + return { + query, + }; +}; diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index 9f8fedd8f..05771747a 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -20,9 +20,24 @@ display: grid; gap: 1rem; width: 60vw; - .wordfilter { - width: 33%; - justify-self: right; + + .buttonsTop { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1rem; + } + + .savedTexts { + display: grid; + gap: 0.5rem; + .title { + color: var(--sub-color); + } + .buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } } textarea { @@ -61,6 +76,36 @@ } } +#savedTextsPopupWrapper { + #savedTextsPopup { + color: var(--sub-color); + background: var(--bg-color); + border-radius: var(--roundness); + padding: 2rem; + display: grid; + gap: 1rem; + width: 400px; + + .title { + font-size: 1.5rem; + color: var(--sub-color); + } + + .list { + display: grid; + gap: 1rem; + .savedText { + display: grid; + gap: 0.5rem; + grid-template-columns: 1fr 3rem; + .button .fas { + pointer-events: none; + } + } + } + } +} + #wordFilterPopupWrapper { #wordFilterPopup { color: var(--sub-color); @@ -367,6 +412,10 @@ } #quoteSearchPopupWrapper { + .highlight { + color: var(--main-color); + } + #quoteSearchPopup { background: var(--bg-color); border-radius: var(--roundness); diff --git a/frontend/static/index.html b/frontend/static/index.html index 7499c86c5..58274b625 100644 --- a/frontend/static/index.html +++ b/frontend/static/index.html @@ -372,9 +372,23 @@