diff --git a/.gitignore b/.gitignore index 2ec771ff..d4f005dd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ static/dist cypress/screenshots cypress/videos + +# Wrangler +.wrangler + +# vscode +.vscode diff --git a/src/home/fetch-github/fetch-and-display-previews.ts b/src/home/fetch-github/fetch-and-display-previews.ts index 746f30fa..5a4d10fd 100644 --- a/src/home/fetch-github/fetch-and-display-previews.ts +++ b/src/home/fetch-github/fetch-and-display-previews.ts @@ -3,6 +3,7 @@ import { taskManager } from "../home"; import { applyAvatarsToIssues, renderGitHubIssues } from "../rendering/render-github-issues"; import { renderOrgHeaderLabel } from "../rendering/render-org-header"; import { closeModal } from "../rendering/render-preview-modal"; +import { filterIssuesBySearch } from "../sorting/filter-issues-by-search"; import { Sorting } from "../sorting/generate-sorting-buttons"; import { sortIssuesController } from "../sorting/sort-issues-controller"; import { checkCacheIntegrityAndSyncTasks } from "./cache-integrity"; @@ -89,3 +90,17 @@ export async function displayGitHubIssues({ renderGitHubIssues(sortedAndFiltered, skipAnimation); applyAvatarsToIssues(); } + +export async function searchDisplayGitHubIssues({ + searchText, + skipAnimation = false, +}: { + searchText: string; + skipAnimation?: boolean; +}) { + const searchResult = filterIssuesBySearch(searchText); + let filteredIssues = searchResult.filter(getProposalsOnlyFilter(isProposalOnlyViewer)); + filteredIssues = filterIssuesByOrganization(filteredIssues); + renderGitHubIssues(filteredIssues, skipAnimation); + applyAvatarsToIssues(); +} diff --git a/src/home/issues-search.ts b/src/home/issues-search.ts new file mode 100644 index 00000000..82be9f40 --- /dev/null +++ b/src/home/issues-search.ts @@ -0,0 +1,159 @@ +import { GitHubIssue } from "./github-types"; +import { TaskManager } from "./task-manager"; +import { SearchResult, SearchWeights, SearchConfig } from "./types/search-types"; +import { SearchScorer } from "./search/search-scorer"; + +export class IssueSearch { + private readonly _weights: SearchWeights = { + title: 0.375, + body: 0.25, + fuzzy: 0.25, + meta: 0.125, + }; + + private readonly _config: SearchConfig = { + fuzzySearchThreshold: 0.7, + exactMatchBonus: 1.0, + fuzzyMatchWeight: 0.7, + }; + + private readonly _searchScorer: SearchScorer; + private _searchableIssues: Map = new Map(); + + constructor(private _taskManager: TaskManager) { + this._searchScorer = new SearchScorer(this._config); + } + + public async initializeIssues(issues: GitHubIssue[]) { + this._searchableIssues.clear(); + issues.forEach((issue) => { + const searchableContent = this._getSearchableContent(issue); + this._searchableIssues.set(issue.id, searchableContent); + }); + } + + public search(searchText: string): Map { + let filterText = searchText.toLowerCase().trim(); + const results = new Map(); + const isFuzzySearchEnabled = filterText.startsWith("?"); + + if (isFuzzySearchEnabled) { + filterText = filterText.slice(1).trim(); + } + + if (!filterText) { + for (const id of this._searchableIssues.keys()) { + results.set(id, this._createEmptyResult()); + } + return results; + } + + const searchTerms = this._preprocessSearchTerms(filterText); + + for (const issueId of this._searchableIssues.keys()) { + const issue = this._taskManager.getGitHubIssueById(issueId); + if (!issue) { + results.set(issueId, this._createEmptyResult(false)); + continue; + } + + const result = this._calculateIssueRelevance(issue, searchTerms, isFuzzySearchEnabled); + results.set(issueId, result); + } + + this._calculateNDCGScore(results); + return results; + } + + private _calculateIssueRelevance(issue: GitHubIssue, searchTerms: string[], enableFuzzy: boolean): SearchResult { + const matchDetails = { + titleMatches: [] as string[], + bodyMatches: [] as string[], + labelMatches: [] as string[], + numberMatch: false, + fuzzyMatches: [] as Array<{ + original: string; + matched: string; + score: number; + }>, + }; + + const searchableContent = this._searchableIssues.get(issue.id) || this._getSearchableContent(issue); + + // Calculate individual scores + const scores = { + title: this._searchScorer.calculateTitleScore(issue, searchTerms, matchDetails), + body: this._searchScorer.calculateBodyScore(issue, searchTerms, matchDetails), + fuzzy: enableFuzzy ? this._searchScorer.calculateFuzzyScore(searchableContent, searchTerms, matchDetails) : 0, + meta: this._searchScorer.calculateMetaScore(issue, searchTerms, matchDetails), + }; + + // Calculate weighted total score + const totalScore = Object.entries(scores).reduce((total, [key, score]) => { + return total + score * this._weights[key as keyof SearchWeights]; + }, 0); + + const isVisible = totalScore > 0 || matchDetails.numberMatch; + + return { + visible: isVisible, + score: isVisible ? totalScore : 0, + matchDetails, + }; + } + + private _calculateNDCGScore(results: Map): number { + const scores = Array.from(results.values()) + .filter((r) => r.visible) + .map((r) => r.score) + .sort((a, b) => b - a); + + if (scores.length === 0) return 0; + + const dcg = scores.reduce((sum, score, index) => { + return sum + (Math.pow(2, score) - 1) / Math.log2(index + 2); + }, 0); + + const idcg = [...scores] + .sort((a, b) => b - a) + .reduce((sum, score, index) => { + return sum + (Math.pow(2, score) - 1) / Math.log2(index + 2); + }, 0); + + return idcg === 0 ? 0 : dcg / idcg; + } + + private _preprocessSearchTerms(searchText: string): string[] { + return searchText + .split(/\s+/) + .filter(Boolean) + .map((term) => term.toLowerCase()); + } + + private _getSearchableContent(issue: GitHubIssue): string { + // Remove URLs from the content + const removeUrls = (text: string): string => { + return text.replace(/(?:https?:\/\/|http?:\/\/|www\.)[^\s]+/g, ""); + }; + + const title = issue.title; + const body = removeUrls(issue.body || ""); + const labels = issue.labels?.map((l) => (typeof l === "object" && l.name ? l.name : "")).join(" ") || ""; + + return `${title} ${body} ${labels}`.toLowerCase(); + } + + private _createEmptyResult(visible: boolean = true): SearchResult { + return { + visible, + score: visible ? 1 : 0, + matchDetails: { + titleMatches: [], + bodyMatches: [], + labelMatches: [], + numberMatch: false, + fuzzyMatches: [], + }, + }; + } +} diff --git a/src/home/search/search-scorer.ts b/src/home/search/search-scorer.ts new file mode 100644 index 00000000..89adad72 --- /dev/null +++ b/src/home/search/search-scorer.ts @@ -0,0 +1,146 @@ +import { GitHubIssue } from "../github-types"; +import { SearchConfig, SearchResult } from "../types/search-types"; +import { StringSimilarity } from "./string-similarity"; + +export class SearchScorer { + constructor(private _config: SearchConfig) {} + + public calculateTitleScore(issue: GitHubIssue, searchTerms: string[], matchDetails: SearchResult["matchDetails"]): number { + let score = 0; + const title = issue.title.toLowerCase(); + const words = title.split(/\s+/); + + searchTerms.forEach((term) => { + if (title.includes(term)) { + matchDetails.titleMatches.push(term); + score += this._config.exactMatchBonus; + + // Apply exponential boost for word beginnings + words.forEach((word) => { + if (word.startsWith(term)) { + // e^(-x) where x is the position of the match relative to word length + const positionBoost = Math.exp(-term.length / word.length); + score += positionBoost; + } + }); + } + }); + + if (searchTerms.length > 1 && title.includes(searchTerms.join(" "))) { + score += 1; + } + return Math.min(score, 3); + } + + public calculateBodyScore(issue: GitHubIssue, searchTerms: string[], matchDetails: SearchResult["matchDetails"]): number { + let score = 0; + const body = (issue.body || "").toLowerCase(); + const words = body.split(/\s+/); + + searchTerms.forEach((term) => { + let termScore = 0; + words.forEach((word) => { + if (word.startsWith(term)) { + // Apply exponential boost for word beginnings + const positionBoost = Math.exp(-term.length / word.length); + termScore += positionBoost; + } + }); + + if (termScore > 0) { + matchDetails.bodyMatches.push(term); + score += Math.min(termScore, 1); + } + + const codeBlockMatches = body.match(/```[\s\S]*?```/g) || []; + codeBlockMatches.forEach((block) => { + if (block.toLowerCase().includes(term)) { + score += 0.5; + } + }); + }); + return Math.min(score, 2); + } + + public calculateMetaScore(issue: GitHubIssue, searchTerms: string[], matchDetails: SearchResult["matchDetails"]): number { + let score = 0; + const numberTerm = searchTerms.find((term) => /^\d+$/.test(term)); + if (numberTerm && issue.number.toString() === numberTerm) { + matchDetails.numberMatch = true; + score += 2; + } + if (issue.labels) { + searchTerms.forEach((term) => { + issue.labels?.forEach((label) => { + if (typeof label === "object" && label.name) { + const labelName = label.name.toLowerCase(); + if (labelName.includes(term)) { + matchDetails.labelMatches.push(label.name); + // Apply exponential boost for label matches at word start + if (labelName.startsWith(term)) { + score += 0.8; + } else { + score += 0.5; + } + } + } + }); + }); + } + + return score; + } + + public calculateFuzzyScore(content: string, searchTerms: string[], matchDetails: SearchResult["matchDetails"]): number { + let score = 0; + const contentWords = this._tokenizeContent(content); + + searchTerms.forEach((searchTerm) => { + let bestMatch = { + word: "", + score: 0, + isWordStart: false, + }; + + contentWords.forEach((word) => { + const similarity = StringSimilarity.calculate(searchTerm, word); + const isWordStart = word.startsWith(searchTerm); + + // Calculate position-based boost + const positionBoost = isWordStart ? Math.exp(-searchTerm.length / word.length) : 0; + const adjustedScore = similarity + positionBoost; + + if (adjustedScore > this._config.fuzzySearchThreshold && adjustedScore > bestMatch.score) { + bestMatch = { + word, + score: adjustedScore, + isWordStart, + }; + } + }); + + if (bestMatch.score > 0) { + matchDetails.fuzzyMatches.push({ + original: searchTerm, + matched: bestMatch.word, + score: bestMatch.score, + }); + + // Apply exponential weight for word-start matches + const finalScore = bestMatch.isWordStart ? bestMatch.score * Math.exp(this._config.fuzzyMatchWeight) : bestMatch.score * this._config.fuzzyMatchWeight; + + score += finalScore; + } + }); + + return Math.min(score, 2); + } + + private _tokenizeContent(content: string): string[] { + return content + .toLowerCase() + .replace(/[^\w\s]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 2); + } +} diff --git a/src/home/search/string-similarity.ts b/src/home/search/string-similarity.ts new file mode 100644 index 00000000..c4b36191 --- /dev/null +++ b/src/home/search/string-similarity.ts @@ -0,0 +1,31 @@ +export class StringSimilarity { + public static calculate(str1: string, str2: string): number { + const maxLen = Math.max(str1.length, str2.length); + if (maxLen === 0) return 1.0; + + const distance = this._calculateLevenshteinDistance(str1, str2); + return 1 - (distance / maxLen); + } + + private static _calculateLevenshteinDistance(str1: string, str2: string): number { + const matrix: number[][] = Array(str2.length + 1).fill(null).map(() => + Array(str1.length + 1).fill(null) + ); + + for (let i = 0; i <= str1.length; i++) matrix[0][i] = i; + for (let j = 0; j <= str2.length; j++) matrix[j][0] = j; + + for (let j = 1; j <= str2.length; j++) { + for (let i = 1; i <= str1.length; i++) { + const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, + matrix[j - 1][i] + 1, + matrix[j - 1][i - 1] + indicator + ); + } + } + + return matrix[str2.length][str1.length]; + } +} diff --git a/src/home/sorting/filter-issues-by-search.ts b/src/home/sorting/filter-issues-by-search.ts new file mode 100644 index 00000000..0e26a85d --- /dev/null +++ b/src/home/sorting/filter-issues-by-search.ts @@ -0,0 +1,13 @@ +import { GitHubIssue } from "../github-types"; +import { taskManager } from "../home"; + +export function filterIssuesBySearch(filterText: string) { + const searchResults = taskManager.issueSearcher.search(filterText); + //Create the new GithubIssue[] array based on the ranking in the searchResults + const sortedIssues = Array.from(searchResults.entries()) + .filter(([, result]) => result.score > 0) + .sort((a, b) => b[1].score - a[1].score) + .map(([id]) => taskManager.getGitHubIssueById(id)) + .filter((issue): issue is GitHubIssue => issue !== undefined); + return sortedIssues; +} diff --git a/src/home/sorting/sorting-manager.ts b/src/home/sorting/sorting-manager.ts index d6d37813..fbbffc20 100644 --- a/src/home/sorting/sorting-manager.ts +++ b/src/home/sorting/sorting-manager.ts @@ -1,5 +1,4 @@ -import { displayGitHubIssues } from "../fetch-github/fetch-and-display-previews"; -import { taskManager } from "../home"; +import { displayGitHubIssues, searchDisplayGitHubIssues } from "../fetch-github/fetch-and-display-previews"; import { renderErrorInModal } from "../rendering/display-popup-modal"; import { Sorting } from "./generate-sorting-buttons"; @@ -13,11 +12,15 @@ export class SortingManager { constructor(filtersId: string, sortingOptions: readonly string[], instanceId: string) { const filters = document.getElementById(filtersId); + if (!filters) throw new Error(`${filtersId} not found`); this._toolBarFilters = filters; this._instanceId = instanceId; - this._filterTextBox = this._generateFilterTextBox(); + + // Initialize sorting buttons first this._sortingButtons = this._generateSortingButtons(sortingOptions); + // Then initialize filter text box + this._filterTextBox = this._generateFilterTextBox(); // Initialize sorting states to 'unsorted' for all options sortingOptions.forEach((option) => { @@ -51,47 +54,59 @@ export class SortingManager { const issuesContainer = document.getElementById("issues-container") as HTMLDivElement; - function filterIssues() { - try { - const filterText = textBox.value.toLowerCase(); - const issues = Array.from(issuesContainer.children) as HTMLDivElement[]; - issues.forEach((issue) => { - const issueId = issue.children[0].getAttribute("data-issue-id"); - issue.classList.add("active"); - if (!issueId) return; - const gitHubIssue = taskManager.getGitHubIssueById(parseInt(issueId)); - if (!gitHubIssue) return; - const searchableProperties = ["title", "body", "number", "html_url"] as const; - const searchableStrings = searchableProperties.map((prop) => gitHubIssue[prop]?.toString().toLowerCase()); - const isVisible = searchableStrings.some((str) => str?.includes(filterText)); - issue.style.display = isVisible ? "block" : "none"; - }); - } catch (error) { - return renderErrorInModal(error as Error); - } - } - // Observer to detect when children are added to the issues container (only once) const observer = new MutationObserver(() => { if (issuesContainer.children.length > 0) { observer.disconnect(); // Stop observing once children are present - if (searchQuery) filterIssues(); // Filter on load if search query exists + if (searchQuery) { + try { + void searchDisplayGitHubIssues({ + searchText: searchQuery, + }); + } catch (error) { + renderErrorInModal(error as Error); + } + } } }); observer.observe(issuesContainer, { childList: true }); textBox.addEventListener("input", () => { const filterText = textBox.value; + // Reset sorting buttons when there is text in search menu + if (filterText) { + this._resetSortButtons(); + } // Update the URL with the search parameter const newURL = new URL(window.location.href); - newURL.searchParams.set("search", filterText); + if (filterText) { + newURL.searchParams.set("search", filterText); + } else { + newURL.searchParams.delete("search"); + } window.history.replaceState({}, "", newURL.toString()); - filterIssues(); // Run the filter function immediately on input + try { + void searchDisplayGitHubIssues({ + searchText: filterText, + }); + } catch (error) { + renderErrorInModal(error as Error); + } }); return textBox; } + private _resetSortButtons() { + this._sortingButtons.querySelectorAll('input[type="radio"]').forEach((input) => { + if (input instanceof HTMLInputElement) { + input.checked = false; + input.setAttribute("data-ordering", ""); + } + }); + this._lastChecked = null; + } + private _generateSortingButtons(sortingOptions: readonly string[]) { const buttons = document.createElement("div"); buttons.className = "labels"; @@ -152,6 +167,19 @@ export class SortingManager { } }); + // Clear search when applying a different sort + this._filterTextBox.value = ""; + const newURL = new URL(window.location.href); + newURL.searchParams.delete("search"); + window.history.replaceState({}, "", newURL.toString()); + + // Reset other buttons + input.parentElement?.childNodes.forEach((node) => { + if (node instanceof HTMLInputElement) { + node.setAttribute("data-ordering", ""); + } + }); + if (newOrdering === "disabled") { this._lastChecked = null; input.checked = false; diff --git a/src/home/task-manager.ts b/src/home/task-manager.ts index 51f7531a..2329c6c1 100644 --- a/src/home/task-manager.ts +++ b/src/home/task-manager.ts @@ -3,12 +3,15 @@ import { getGitHubAccessToken } from "./getters/get-github-access-token"; import { getIssuesFromCache } from "./getters/get-indexed-db"; import { setLocalStore } from "./getters/get-local-store"; import { GITHUB_TASKS_STORAGE_KEY, GitHubIssue } from "./github-types"; +import { IssueSearch } from "./issues-search"; export class TaskManager { private _tasks: GitHubIssue[] = []; private _container: HTMLDivElement; + public issueSearcher: IssueSearch; constructor(container: HTMLDivElement) { this._container = container; + this.issueSearcher = new IssueSearch(this); } // Syncs tasks by getting issues from cache, writing them to storage and then fetching avatars @@ -18,6 +21,9 @@ export class TaskManager { this._tasks = issues; void this._writeToStorage(issues); + // Initialize issues for search operations + await this.issueSearcher.initializeIssues(issues); + await fetchAvatars(); } diff --git a/src/home/types/search-types.ts b/src/home/types/search-types.ts new file mode 100644 index 00000000..8b25d226 --- /dev/null +++ b/src/home/types/search-types.ts @@ -0,0 +1,28 @@ +export interface SearchResult { + visible: boolean; + score: number; + matchDetails: { + titleMatches: string[]; + bodyMatches: string[]; + labelMatches: string[]; + numberMatch: boolean; + fuzzyMatches: Array<{ + original: string; + matched: string; + score: number; + }>; + }; +} + +export interface SearchWeights { + title: number; + body: number; + fuzzy: number; + meta: number; +} + +export interface SearchConfig { + fuzzySearchThreshold: number; + exactMatchBonus: number; + fuzzyMatchWeight: number; +}