github-search-cli 0.8.2 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -50
- package/bin/ghfind.js +3 -13
- package/dist/chunk-TUMYR2IC.js +57 -0
- package/dist/cli.js +3761 -0
- package/dist/config-DFL44OVL.js +14 -0
- package/package.json +3 -1
- package/scripts/postinstall.js +106 -106
- package/src/bookmarks.ts +4 -17
- package/src/cli.ts +90 -61
- package/src/config.ts +2 -2
- package/src/history.ts +16 -52
- package/src/notifications.ts +5 -12
- package/src/output.ts +124 -55
- package/src/saved-searches.ts +4 -12
- package/src/search-state.ts +229 -0
- package/src/search.ts +99 -2
- package/src/session.ts +6 -18
- package/src/storage.ts +106 -0
- package/src/themes.ts +47 -7
- package/src/trending.ts +27 -150
- package/src/tui.ts +1087 -270
- package/src/types.ts +2 -0
- package/src/update-check.ts +162 -0
- package/src/watch.ts +23 -11
- package/vendor/bun.exe +0 -0
package/src/output.ts
CHANGED
|
@@ -1,68 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Output module — unified formatting, export, and pipe execution.
|
|
3
|
+
*
|
|
4
|
+
* All format functions (JSON, CSV, Markdown, text, urls, names, ssh-urls,
|
|
5
|
+
* clone-commands, ids) register in a single registry. One `format()` function
|
|
6
|
+
* dispatches. `exportToFile` and `pipeExec` are methods on the same module.
|
|
7
|
+
*/
|
|
8
|
+
import { writeFileSync } from "fs";
|
|
9
|
+
import type { Repo } from "./types";
|
|
10
|
+
import { openUrl } from "./open-url";
|
|
11
|
+
|
|
12
|
+
export type Format =
|
|
13
|
+
| "json" | "csv" | "markdown" | "text"
|
|
14
|
+
| "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
15
|
+
|
|
16
|
+
export type FormatFn = (repos: Repo[]) => string;
|
|
17
|
+
|
|
18
|
+
/** Registry of all format functions. Adding a format = 1 line here. */
|
|
19
|
+
const FORMATS: Map<Format, FormatFn> = new Map([
|
|
20
|
+
// Export formats
|
|
21
|
+
["json", formatJson],
|
|
22
|
+
["csv", formatCsv],
|
|
23
|
+
["markdown", formatMarkdown],
|
|
24
|
+
["text", formatText],
|
|
25
|
+
// Pipe formats
|
|
26
|
+
["urls", formatUrls],
|
|
27
|
+
["names", formatNames],
|
|
28
|
+
["ssh-urls", formatSshUrls],
|
|
29
|
+
["clone-commands", formatCloneCommands],
|
|
30
|
+
["ids", formatIds],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// ── Export format functions ────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function formatJson(repos: Repo[]): string {
|
|
36
|
+
return JSON.stringify(repos, null, 2);
|
|
10
37
|
}
|
|
11
38
|
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
|
|
39
|
+
function csvEscape(val: unknown): string {
|
|
40
|
+
const str = String(val ?? "");
|
|
41
|
+
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
|
|
42
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
43
|
+
}
|
|
44
|
+
return str;
|
|
16
45
|
}
|
|
17
46
|
|
|
18
|
-
export function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
totalCount: response.totalCount,
|
|
23
|
-
count: response.repos.length,
|
|
24
|
-
rateLimited: response.rateLimited,
|
|
25
|
-
results: response.repos,
|
|
26
|
-
},
|
|
27
|
-
null,
|
|
28
|
-
2,
|
|
47
|
+
export function formatCsv(repos: Repo[]): string {
|
|
48
|
+
const header = "rank,full_name,stars,forks,language,url";
|
|
49
|
+
const rows = repos.map((r, i) =>
|
|
50
|
+
`${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
|
|
29
51
|
);
|
|
52
|
+
return [header, ...rows].join("\n");
|
|
30
53
|
}
|
|
31
54
|
|
|
32
|
-
export function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
export function formatMarkdown(repos: Repo[]): string {
|
|
56
|
+
const header = "| # | Repo | Stars | Forks | Language |";
|
|
57
|
+
const sep = "|---|------|-------|-------|----------|";
|
|
58
|
+
const rows = repos.map(
|
|
59
|
+
(r, i) =>
|
|
60
|
+
`| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
61
|
+
);
|
|
62
|
+
return [header, sep, ...rows].join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatText(repos: Repo[]): string {
|
|
66
|
+
return repos.map((r, i) => `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`).join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Pipe format functions ──────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function formatUrls(repos: Repo[]): string {
|
|
72
|
+
return repos.map((r) => r.url).join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatNames(repos: Repo[]): string {
|
|
76
|
+
return repos.map((r) => r.fullName).join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatSshUrls(repos: Repo[]): string {
|
|
80
|
+
return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatCloneCommands(repos: Repo[]): string {
|
|
84
|
+
return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatIds(repos: Repo[]): string {
|
|
88
|
+
return repos.map((r) => String(r.id)).join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
40
92
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
93
|
+
/** Format repos using the registered format function. */
|
|
94
|
+
export function format(repos: Repo[], format: Format): string {
|
|
95
|
+
const fn = FORMATS.get(format);
|
|
96
|
+
if (!fn) throw new Error(`Unknown format: ${format}`);
|
|
97
|
+
return fn(repos);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Export repos to a file. Returns the file path. */
|
|
101
|
+
export function exportToFile(repos: Repo[], fmt: Format): string {
|
|
102
|
+
const ext = fmt === "markdown" ? "md" : fmt;
|
|
103
|
+
const ts = Date.now();
|
|
104
|
+
const path = `./ghfind-export-${ts}.${ext}`;
|
|
105
|
+
writeFileSync(path, format(repos, fmt), "utf-8");
|
|
106
|
+
return path;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Execute a pipe target (clone, open) on repos. */
|
|
110
|
+
export async function pipeExec(repos: Repo[], target: string): Promise<void> {
|
|
111
|
+
for (const repo of repos) {
|
|
112
|
+
const url = repo.url;
|
|
113
|
+
switch (target) {
|
|
114
|
+
case "open":
|
|
115
|
+
await openUrl(url);
|
|
116
|
+
break;
|
|
117
|
+
case "clone":
|
|
118
|
+
console.log(`git clone git@github.com:${repo.fullName}.git`);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
44
121
|
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Backward-compatible re-exports ─────────────────────────────────────
|
|
45
125
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
lines.push("");
|
|
49
|
-
});
|
|
126
|
+
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
127
|
+
export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
50
128
|
|
|
51
|
-
|
|
129
|
+
/** @deprecated Use `format()` instead. */
|
|
130
|
+
export function formatRepos(repos: Repo[], fmt: ExportFormat): string {
|
|
131
|
+
return format(repos, fmt);
|
|
52
132
|
}
|
|
53
133
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const tags: string[] = [];
|
|
59
|
-
if (repo.archived) tags.push("archived");
|
|
60
|
-
if (repo.isFork) tags.push("fork");
|
|
61
|
-
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
62
|
-
|
|
63
|
-
const header = `${index}. ${repo.fullName}${tagStr}`;
|
|
64
|
-
const meta = ` ★ ${stars} • ${lang} • updated ${updated}`;
|
|
65
|
-
const desc = repo.description ? ` ${repo.description}` : " (no description)";
|
|
66
|
-
const url = ` ${repo.url}`;
|
|
67
|
-
return [header, meta, desc, url].join("\n");
|
|
68
|
-
}
|
|
134
|
+
/** @deprecated Use `format()` instead. */
|
|
135
|
+
export function formatLines(repos: Repo[], fmt: FormatLine): string {
|
|
136
|
+
return format(repos, fmt);
|
|
137
|
+
}
|
package/src/saved-searches.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Saved searches — named queries you can re-run anytime.
|
|
3
3
|
*/
|
|
4
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
4
|
import type { SavedSearch, SortStrategy } from "./types";
|
|
7
|
-
import {
|
|
5
|
+
import { readJSON, writeJSON } from "./storage";
|
|
8
6
|
|
|
9
|
-
const SAVED_FILE =
|
|
7
|
+
const SAVED_FILE = "saved-searches.json";
|
|
10
8
|
|
|
11
9
|
function loadRaw(): SavedSearch[] {
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
return JSON.parse(readFileSync(SAVED_FILE, "utf-8")) as SavedSearch[];
|
|
15
|
-
} catch {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
10
|
+
return readJSON<SavedSearch[]>(SAVED_FILE, []);
|
|
18
11
|
}
|
|
19
12
|
|
|
20
13
|
function saveRaw(searches: SavedSearch[]): void {
|
|
21
|
-
|
|
22
|
-
writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
|
|
14
|
+
writeJSON(SAVED_FILE, searches);
|
|
23
15
|
}
|
|
24
16
|
|
|
25
17
|
/** Get all saved searches, sorted by last run time (most recent first). */
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchState — owns all TUI state and domain calls.
|
|
3
|
+
*
|
|
4
|
+
* The TUI view layer reads from SearchState and dispatches actions.
|
|
5
|
+
* This module is testable without the OpenTUI renderer.
|
|
6
|
+
*/
|
|
7
|
+
import type { Repo, SearchOptions, SortStrategy, SessionState } from "./types";
|
|
8
|
+
import type { TabName } from "./trending";
|
|
9
|
+
import { tabSince } from "./trending";
|
|
10
|
+
import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch, SearchModule, TrendingAdapter } from "./search";
|
|
11
|
+
import { restoreSession, saveSession } from "./session";
|
|
12
|
+
import { appendHistory } from "./history";
|
|
13
|
+
import { toggleBookmark, isBookmarked } from "./bookmarks";
|
|
14
|
+
import { saveSearch, getSavedSearches, touchSavedSearch } from "./saved-searches";
|
|
15
|
+
import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
|
|
16
|
+
import { fetchTopics } from "./explore";
|
|
17
|
+
import { loadConfig } from "./config";
|
|
18
|
+
|
|
19
|
+
export type Overlay =
|
|
20
|
+
| "none" | "history" | "bookmarks" | "saved" | "help"
|
|
21
|
+
| "topics" | "export" | "compare" | "notifications"
|
|
22
|
+
| "share" | "leader" | "readme";
|
|
23
|
+
|
|
24
|
+
export interface SearchState {
|
|
25
|
+
// Core search state
|
|
26
|
+
currentRepos: Repo[];
|
|
27
|
+
currentSort: SortStrategy;
|
|
28
|
+
currentLimit: number;
|
|
29
|
+
currentQueryInput: string;
|
|
30
|
+
isLoading: boolean;
|
|
31
|
+
currentMode: "search" | "trending";
|
|
32
|
+
currentPage: number;
|
|
33
|
+
totalCount: number;
|
|
34
|
+
|
|
35
|
+
// Trending
|
|
36
|
+
trendingTab: TabName;
|
|
37
|
+
trendingPeriod: string;
|
|
38
|
+
|
|
39
|
+
// UI overlays
|
|
40
|
+
currentOverlay: Overlay;
|
|
41
|
+
deepDiveActive: boolean;
|
|
42
|
+
compareList: Repo[];
|
|
43
|
+
graphFullscreen: boolean;
|
|
44
|
+
chartCommitData: number[];
|
|
45
|
+
quitArmed: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class SearchStateManager {
|
|
49
|
+
private config = loadConfig();
|
|
50
|
+
private provider = createGitHubSearch(undefined, this.config.githubTokens);
|
|
51
|
+
private trendingSearch = new SearchModule(new TrendingAdapter());
|
|
52
|
+
|
|
53
|
+
state: SearchState = {
|
|
54
|
+
currentRepos: [],
|
|
55
|
+
currentSort: this.config.defaultSort,
|
|
56
|
+
currentLimit: this.config.defaultLimit,
|
|
57
|
+
currentQueryInput: "",
|
|
58
|
+
isLoading: false,
|
|
59
|
+
currentMode: "search",
|
|
60
|
+
currentPage: 1,
|
|
61
|
+
totalCount: 0,
|
|
62
|
+
trendingTab: "This Week",
|
|
63
|
+
trendingPeriod: "this week",
|
|
64
|
+
currentOverlay: "none",
|
|
65
|
+
deepDiveActive: false,
|
|
66
|
+
compareList: [],
|
|
67
|
+
graphFullscreen: false,
|
|
68
|
+
chartCommitData: [],
|
|
69
|
+
quitArmed: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
const session = restoreSession();
|
|
74
|
+
if (session) {
|
|
75
|
+
this.state.currentMode = session.mode;
|
|
76
|
+
this.state.currentQueryInput = session.query;
|
|
77
|
+
this.state.currentSort = session.sort;
|
|
78
|
+
this.state.currentLimit = session.limit;
|
|
79
|
+
this.state.trendingTab = (session.trendingTab as TabName) ?? "This Week";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Search ──
|
|
84
|
+
|
|
85
|
+
async search(query: string, page: number = 1): Promise<void> {
|
|
86
|
+
this.state.isLoading = true;
|
|
87
|
+
this.state.currentQueryInput = query;
|
|
88
|
+
this.state.currentPage = page;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = applyFlagFilters(parseQuery(query), {});
|
|
91
|
+
const response = await this.provider.search(parsed, {
|
|
92
|
+
limit: this.state.currentLimit,
|
|
93
|
+
sort: this.state.currentSort,
|
|
94
|
+
json: false,
|
|
95
|
+
verbose: false,
|
|
96
|
+
token: this.config.githubToken,
|
|
97
|
+
page,
|
|
98
|
+
});
|
|
99
|
+
this.state.currentRepos = rankRepos(response.repos, this.state.currentSort);
|
|
100
|
+
this.state.totalCount = response.totalCount;
|
|
101
|
+
this.state.currentMode = "search";
|
|
102
|
+
appendHistory({ query, mode: "search", timestamp: Date.now(), resultCount: response.repos.length });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw err;
|
|
105
|
+
} finally {
|
|
106
|
+
this.state.isLoading = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async nextPage(): Promise<void> {
|
|
111
|
+
await this.search(this.state.currentQueryInput, this.state.currentPage + 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async prevPage(): Promise<void> {
|
|
115
|
+
if (this.state.currentPage > 1) {
|
|
116
|
+
await this.search(this.state.currentQueryInput, this.state.currentPage - 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Trending ──
|
|
121
|
+
|
|
122
|
+
async loadTrending(tab: TabName): Promise<void> {
|
|
123
|
+
this.state.isLoading = true;
|
|
124
|
+
this.state.trendingTab = tab;
|
|
125
|
+
this.state.currentMode = "trending";
|
|
126
|
+
try {
|
|
127
|
+
const response = await this.trendingSearch.search(
|
|
128
|
+
{ keywords: [], qualifiers: [], raw: "trending" },
|
|
129
|
+
{ limit: 25, sort: "stars", json: false, verbose: false, trendingSince: tabSince(tab) },
|
|
130
|
+
);
|
|
131
|
+
this.state.currentRepos = response.repos;
|
|
132
|
+
this.state.totalCount = response.totalCount;
|
|
133
|
+
appendHistory({ query: `trending:${tab}`, mode: "trending", tab, timestamp: Date.now(), resultCount: response.repos.length });
|
|
134
|
+
} finally {
|
|
135
|
+
this.state.isLoading = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Bookmarks ──
|
|
140
|
+
|
|
141
|
+
toggleBookmark(repo: Repo): boolean {
|
|
142
|
+
return toggleBookmark(repo);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
isBookmarked(fullName: string): boolean {
|
|
146
|
+
return isBookmarked(fullName);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Saved searches ──
|
|
150
|
+
|
|
151
|
+
saveCurrentSearch(name: string): void {
|
|
152
|
+
saveSearch(name, this.state.currentQueryInput, this.state.currentMode, this.state.currentSort, this.state.currentLimit);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getSavedSearches() {
|
|
156
|
+
return getSavedSearches();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
touchSavedSearch(name: string): void {
|
|
160
|
+
touchSavedSearch(name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Deep dive ──
|
|
164
|
+
|
|
165
|
+
async deepDive(repo: Repo): Promise<string> {
|
|
166
|
+
this.state.deepDiveActive = true;
|
|
167
|
+
try {
|
|
168
|
+
const data = await fetchDeepDive(repo, this.config.githubToken);
|
|
169
|
+
return buildDeepDiveText(data);
|
|
170
|
+
} finally {
|
|
171
|
+
this.state.deepDiveActive = false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Compare ──
|
|
176
|
+
|
|
177
|
+
addToCompare(repo: Repo): void {
|
|
178
|
+
if (!this.state.compareList.some((r) => r.fullName === repo.fullName)) {
|
|
179
|
+
this.state.compareList.push(repo);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
removeFromCompare(fullName: string): void {
|
|
184
|
+
this.state.compareList = this.state.compareList.filter((r) => r.fullName !== fullName);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
clearCompare(): void {
|
|
188
|
+
this.state.compareList = [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Session ──
|
|
192
|
+
|
|
193
|
+
saveSession(): void {
|
|
194
|
+
const s: SessionState = {
|
|
195
|
+
mode: this.state.currentMode,
|
|
196
|
+
query: this.state.currentQueryInput,
|
|
197
|
+
sort: this.state.currentSort,
|
|
198
|
+
limit: this.state.currentLimit,
|
|
199
|
+
trendingTab: this.state.trendingTab,
|
|
200
|
+
};
|
|
201
|
+
saveSession(s);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Setters ──
|
|
205
|
+
|
|
206
|
+
setSort(sort: SortStrategy): void {
|
|
207
|
+
this.state.currentSort = sort;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setLimit(limit: number): void {
|
|
211
|
+
this.state.currentLimit = limit;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setOverlay(overlay: Overlay): void {
|
|
215
|
+
this.state.currentOverlay = overlay;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
setGraphFullscreen(fullscreen: boolean): void {
|
|
219
|
+
this.state.graphFullscreen = fullscreen;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setChartCommitData(data: number[]): void {
|
|
223
|
+
this.state.chartCommitData = data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setQuitArmed(armed: boolean): void {
|
|
227
|
+
this.state.quitArmed = armed;
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -467,6 +467,96 @@ function appendPage(url: string, page: number, perPage: number): string {
|
|
|
467
467
|
return u.toString();
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
// ─── Trending adapter ─────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
const TRENDING_URL = "https://github.com/trending";
|
|
473
|
+
|
|
474
|
+
interface RawTrendingRepo {
|
|
475
|
+
rank: number;
|
|
476
|
+
owner: string;
|
|
477
|
+
name: string;
|
|
478
|
+
stars: number;
|
|
479
|
+
starsToday: number;
|
|
480
|
+
language: string;
|
|
481
|
+
description: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parseTrendingHtml(html: string): RawTrendingRepo[] {
|
|
485
|
+
const flat = html.replace(/>\s+</g, "><").replace(/\s+/g, " ");
|
|
486
|
+
const repos: RawTrendingRepo[] = [];
|
|
487
|
+
const articleRe = /<article class="Box-row">(.*?)<\/article>/gi;
|
|
488
|
+
let match: RegExpExecArray | null;
|
|
489
|
+
let rank = 0;
|
|
490
|
+
while ((match = articleRe.exec(flat)) !== null) {
|
|
491
|
+
rank++;
|
|
492
|
+
const block = match[1];
|
|
493
|
+
const repoMatch = block.match(/href="\/([^\/\"]+)\/([^\"?#]+)"[^>]*class="Link"/);
|
|
494
|
+
if (!repoMatch) continue;
|
|
495
|
+
const owner = repoMatch[1];
|
|
496
|
+
const name = repoMatch[2];
|
|
497
|
+
const starsMatch = block.match(/href="\/[^\/\"]+\/[^\/\"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/);
|
|
498
|
+
const stars = starsMatch ? parseInt(starsMatch[1].replace(/,/g, ""), 10) : 0;
|
|
499
|
+
const growthMatch = block.match(/(\d[\d,]*)\s+stars\s+(today|this\s+\w+)/);
|
|
500
|
+
const starsToday = growthMatch ? parseInt(growthMatch[1].replace(/,/g, ""), 10) : 0;
|
|
501
|
+
const langMatch = block.match(/itemprop="programmingLanguage">([^<]+)</);
|
|
502
|
+
const language = langMatch ? langMatch[1].trim() : "";
|
|
503
|
+
const descMatch = block.match(/<p[^>]*class="[^"]*color-fg-muted[^"]*"[^>]*>([^<]+)</);
|
|
504
|
+
const description = descMatch ? descMatch[1].trim() : "";
|
|
505
|
+
repos.push({ rank, owner, name, stars, starsToday, language, description });
|
|
506
|
+
}
|
|
507
|
+
return repos;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function trendingRepoToRepo(r: RawTrendingRepo): Repo {
|
|
511
|
+
return {
|
|
512
|
+
id: 0,
|
|
513
|
+
fullName: `${r.owner}/${r.name}`,
|
|
514
|
+
name: r.name,
|
|
515
|
+
owner: r.owner,
|
|
516
|
+
description: r.description,
|
|
517
|
+
url: `https://github.com/${r.owner}/${r.name}`,
|
|
518
|
+
stars: r.stars,
|
|
519
|
+
forks: 0,
|
|
520
|
+
watchers: 0,
|
|
521
|
+
language: r.language,
|
|
522
|
+
topics: [],
|
|
523
|
+
archived: false,
|
|
524
|
+
isFork: false,
|
|
525
|
+
private: false,
|
|
526
|
+
createdAt: "",
|
|
527
|
+
updatedAt: "",
|
|
528
|
+
pushedAt: "",
|
|
529
|
+
score: r.starsToday,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Trending adapter — scrapes github.com/trending HTML and returns SearchResponse.
|
|
535
|
+
*/
|
|
536
|
+
export class TrendingAdapter implements SearchAdapter {
|
|
537
|
+
readonly name = "trending";
|
|
538
|
+
private readonly logger: Logger;
|
|
539
|
+
|
|
540
|
+
constructor(logger: Logger = noopLogger) {
|
|
541
|
+
this.logger = logger;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async search(_query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
|
|
545
|
+
const since = options.trendingSince ?? "daily";
|
|
546
|
+
const url = since === "daily" ? TRENDING_URL : `${TRENDING_URL}?since=${since}`;
|
|
547
|
+
let res: Response;
|
|
548
|
+
try {
|
|
549
|
+
res = await fetch(url, { headers: { "User-Agent": "ghfind" } });
|
|
550
|
+
} catch {
|
|
551
|
+
throw new NetworkError();
|
|
552
|
+
}
|
|
553
|
+
if (!res.ok) throw new NetworkError();
|
|
554
|
+
const html = await res.text();
|
|
555
|
+
const repos = parseTrendingHtml(html).map(trendingRepoToRepo);
|
|
556
|
+
return { totalCount: repos.length, repos, rateLimited: false };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
470
560
|
// ─── In-memory adapter (for tests) ────────────────────────────────────
|
|
471
561
|
|
|
472
562
|
/**
|
|
@@ -551,5 +641,12 @@ export function createGitHubSearch(
|
|
|
551
641
|
return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
|
|
552
642
|
}
|
|
553
643
|
|
|
554
|
-
|
|
555
|
-
|
|
644
|
+
/**
|
|
645
|
+
* Create a SearchModule backed by the Trending adapter.
|
|
646
|
+
*/
|
|
647
|
+
export function createTrendingSearch(
|
|
648
|
+
logger: Logger = noopLogger,
|
|
649
|
+
): SearchModule {
|
|
650
|
+
return new SearchModule(new TrendingAdapter(logger), logger);
|
|
651
|
+
}
|
|
652
|
+
|
package/src/session.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session restore — save/restore UI state between restarts.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type { SessionState, SortStrategy } from "./types";
|
|
7
|
-
import { stateDir } from "./config";
|
|
4
|
+
import type { SessionState } from "./types";
|
|
5
|
+
import { readJSON, writeJSON } from "./storage";
|
|
8
6
|
|
|
9
|
-
const SESSION_FILE =
|
|
7
|
+
const SESSION_FILE = "session.json";
|
|
10
8
|
|
|
11
9
|
export function saveSession(state: SessionState): void {
|
|
12
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
13
10
|
try {
|
|
14
|
-
|
|
11
|
+
writeJSON(SESSION_FILE, state);
|
|
15
12
|
} catch (err) {
|
|
16
13
|
if (process.env.DEBUG)
|
|
17
14
|
console.error(`[ghfind] Failed to save session: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -19,18 +16,9 @@ export function saveSession(state: SessionState): void {
|
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
export function restoreSession(): SessionState | null {
|
|
22
|
-
|
|
23
|
-
const raw = readFileSync(SESSION_FILE, "utf-8");
|
|
24
|
-
return JSON.parse(raw) as SessionState;
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
19
|
+
return readJSON<SessionState | null>(SESSION_FILE, null);
|
|
28
20
|
}
|
|
29
21
|
|
|
30
22
|
export function clearSession(): void {
|
|
31
|
-
|
|
32
|
-
writeFileSync(SESSION_FILE, "");
|
|
33
|
-
} catch {
|
|
34
|
-
// non-critical
|
|
35
|
-
}
|
|
23
|
+
writeJSON(SESSION_FILE, null);
|
|
36
24
|
}
|