import {getUsername} from '.'; type Source = HTMLAnchorElement | URL | string | string[][] | Record | URLSearchParams; const queryPartsRegExp = /(?:[^\s"]+|"[^"]*")+/g; function splitQueryString(query: string): string[] { return query.match(queryPartsRegExp) ?? []; } // Remove all keywords from array except the last occurrence of one of the keywords. function deduplicateKeywords(array: string[], ...keywords: string[]): string[] { const deduplicated = []; let wasKeywordFound = false; for (const current of array.reverse()) { const isKeyword = keywords.includes(current); if (!isKeyword || !wasKeywordFound) { deduplicated.unshift(current); wasKeywordFound = wasKeywordFound || isKeyword; } } return deduplicated; } function cleanQueryParts(parts: string[]): string[] { return deduplicateKeywords(parts, 'is:issue', 'is:pr'); } /** Parser/Mutator of GitHub's search query directly on anchors and URL-like objects. Notice: if the or `location` changes outside SearchQuery, `get()` will return an outdated value. */ export default class SearchQuery { link?: HTMLAnchorElement; searchParams: URLSearchParams; constructor(link: Source) { if (link instanceof HTMLAnchorElement) { this.link = link; this.searchParams = new URLSearchParams(link.search); // Keep `.search` property up to date with this `searchParams` const nativeSet = this.searchParams.set; this.searchParams.set = (name, value) => { nativeSet.call(this.searchParams, name, value); link.search = String(this.searchParams); }; } else if (link instanceof URL) { this.searchParams = link.searchParams; } else { this.searchParams = new URLSearchParams(link); } // Ensure the query string is set and cleaned up this.set(this.get()); } static escapeValue(value: string): string { return value.includes(' ') ? `"${value}"` : value; } get(): string { const currentQuery = this.searchParams.get('q'); if (typeof currentQuery === 'string') { return currentQuery; } if (!this.link) { return ''; } // Query-less URLs imply some queries. // When we explicitly set ?q=* they're overridden, so they need to be manually added again. const queries = []; // Repo example: is:issue is:open queries.push(/\/pulls\/?$/.test(this.link.pathname) ? 'is:pr' : 'is:issue', 'is:open'); // Header nav example: is:open is:issue author:you archived:false if (this.link.pathname === '/issues' || this.link.pathname === '/pulls') { if (this.searchParams.has('user')) { // #1211 queries.push(`user:${this.searchParams.get('user')!}`); } else { queries.push(`author:${getUsername()!}`); } queries.push('archived:false'); } return queries.join(' '); } getQueryParts(): string[] { return splitQueryString(this.get()); } set(query: string): void { const parts = splitQueryString(query); const cleaned = cleanQueryParts(parts); this.searchParams.set('q', cleaned.join(' ')); } edit(callback: (query: string) => string): void { this.set(callback(this.get())); } replace(searchValue: string | RegExp, replaceValue: string): void { this.set(this.get().replace(searchValue, replaceValue)); } remove(...queryPartToRemove: string[]): void { const newQuery = this .getQueryParts() .filter(queryPart => !queryPartToRemove.includes(queryPart)) .join(' '); this.set(newQuery); } add(...queryParts: string[]): void { const newQuery = this.getQueryParts(); newQuery.push(...queryParts); this.set(newQuery.join(' ')); } includes(...searchStrings: string[]): boolean { return this.getQueryParts().some(queryPart => searchStrings.includes(queryPart)); } }