1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
|
const queryPartsRegExp = /(?:[^\s"]+|"[^"]*")+/g;
const labelLinkRegex = /^(?:\/[^/]+){2}\/labels\/([^/]+)\/?$/;
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');
}
type Source = Location | HTMLAnchorElement | Record<string, string>;
/**
Parser/Mutator of GitHub's search query directly on anchors and URL-like objects.
Notice: if the <a> or `location` changes outside SearchQuery, `get()` will return an outdated value.
*/
export default class SearchQuery {
static escapeValue(value: string): string {
return value.includes(' ') ? `"${value}"` : value;
}
static from(source: Source): SearchQuery {
if (source instanceof Location || source instanceof HTMLAnchorElement) {
return new SearchQuery(source.href);
}
const url = new URL('https://github.com');
for (const [name, value] of Object.entries(source)) {
url.searchParams.set(name, value);
}
return new SearchQuery(url);
}
private readonly url: URL;
private queryParts: string[];
constructor(url: string | URL, base?: string) {
this.url = new URL(String(url), base);
this.queryParts = [];
const currentQuery = this.url.searchParams.get('q');
if (typeof currentQuery === 'string') {
this.queryParts = splitQueryString(currentQuery);
return;
}
// Parse label links #5176
const labelName = labelLinkRegex.exec(this.url.pathname)?.[1];
if (labelName) {
this.queryParts = ['is:open', 'label:' + SearchQuery.escapeValue(decodeURIComponent(labelName))];
return;
}
// Query-less URLs imply some queries.
// When we explicitly set ?q=* they're overridden, so they need to be manually added again.
// Repo example: is:issue is:open
this.queryParts.push(/\/pulls\/?$/.test(this.url.pathname) ? 'is:pr' : 'is:issue', 'is:open');
// Header nav example: is:open is:issue author:you archived:false
if (this.url.pathname === '/issues' || this.url.pathname === '/pulls') {
if (this.url.searchParams.has('user')) { // #1211
this.queryParts.push('user:' + this.url.searchParams.get('user')!);
} else {
this.queryParts.push('author:@me');
}
this.queryParts.push('archived:false');
}
}
getQueryParts(): string[] {
return cleanQueryParts(this.queryParts);
}
get(): string {
return this.getQueryParts().join(' ');
}
set(query: string): this {
this.queryParts = splitQueryString(query);
return this;
}
get searchParams(): URLSearchParams {
return this.url.searchParams;
}
get href(): string {
this.url.searchParams.set('q', this.get());
if (labelLinkRegex.test(this.url.pathname)) {
// Avoid a redirection to the conversation list that would drop the search query #5176
this.url.pathname = this.url.pathname.replace(/\/labels\/.+$/, '/issues');
}
return this.url.href;
}
edit(callback: (queryParts: string[]) => string[]): this {
this.queryParts = callback(this.getQueryParts());
return this;
}
replace(searchValue: string | RegExp, replaceValue: string): this {
this.set(this.get().replace(searchValue, replaceValue));
return this;
}
remove(...queryPartsToRemove: string[]): this {
this.queryParts = this.getQueryParts().filter(queryPart => !queryPartsToRemove.includes(queryPart));
return this;
}
add(...queryPartsToAdd: string[]): this {
this.queryParts.push(...queryPartsToAdd);
return this;
}
includes(...searchStrings: string[]): boolean {
return this.getQueryParts().some(queryPart => searchStrings.includes(queryPart));
}
}
|