github-search-cli 0.6.1 → 0.8.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 +145 -188
- package/bin/ghfind.js +14 -0
- package/completions/{search-cli.bash → ghfind.bash} +4 -4
- package/completions/ghfind.fish +19 -0
- package/completions/{search-cli.zsh → ghfind.zsh} +27 -27
- package/demo.svg +66 -0
- package/package.json +18 -9
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +239 -242
- package/src/compare.ts +46 -46
- package/src/config.ts +65 -66
- package/src/deepdive.ts +118 -124
- package/src/errors.ts +59 -62
- package/src/explore.ts +29 -45
- package/src/export.ts +63 -56
- package/src/help.ts +112 -46
- package/src/history.ts +91 -91
- package/src/init.ts +55 -47
- package/src/notifications.ts +65 -69
- package/src/open-url.ts +16 -0
- package/src/output.ts +67 -73
- package/src/pipe.ts +54 -65
- package/src/saved-searches.ts +58 -58
- package/src/search.ts +555 -0
- package/src/session.ts +36 -35
- package/src/share.ts +54 -49
- package/src/status.ts +61 -61
- package/src/themes.ts +52 -52
- package/src/tips.ts +27 -27
- package/src/trending.ts +556 -587
- package/src/tui.ts +1665 -1469
- package/src/types.ts +139 -147
- package/src/watch.ts +62 -53
- package/bin/search-cli.js +0 -17
- package/completions/search-cli.fish +0 -19
- package/src/cache.ts +0 -64
- package/src/normalizer.ts +0 -66
- package/src/provider.ts +0 -142
- package/src/query.ts +0 -251
- package/src/ranking.ts +0 -55
package/src/notifications.ts
CHANGED
|
@@ -1,69 +1,65 @@
|
|
|
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
|
-
case "
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
export function dismissAll(): void {
|
|
67
|
-
notifs = [];
|
|
68
|
-
save();
|
|
69
|
-
}
|
|
1
|
+
/** Notification center — ephemeral alerts for rate limits, watch changes, etc. */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { stateDir } from "./config";
|
|
5
|
+
|
|
6
|
+
export interface Notification {
|
|
7
|
+
id: number;
|
|
8
|
+
type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
|
|
9
|
+
icon: string;
|
|
10
|
+
message: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let nextId = 1;
|
|
15
|
+
let notifs: Notification[] = [];
|
|
16
|
+
const MAX_NOTIFS = 50;
|
|
17
|
+
const FILE = join(stateDir(), "notifications.json");
|
|
18
|
+
|
|
19
|
+
function load(): void {
|
|
20
|
+
try {
|
|
21
|
+
notifs = JSON.parse(readFileSync(FILE, "utf-8"));
|
|
22
|
+
nextId = (Math.max(...notifs.map((n) => n.id), 0) + 1);
|
|
23
|
+
} catch {
|
|
24
|
+
notifs = [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function save(): void {
|
|
29
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
30
|
+
writeFileSync(FILE, JSON.stringify(notifs));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function addNotification(type: Notification["type"], message: string): Notification {
|
|
34
|
+
if (notifs.length === 0) load();
|
|
35
|
+
const n: Notification = { id: nextId++, type, icon: iconFor(type), message, timestamp: Date.now() };
|
|
36
|
+
notifs.unshift(n);
|
|
37
|
+
if (notifs.length > MAX_NOTIFS) notifs = notifs.slice(0, MAX_NOTIFS);
|
|
38
|
+
save();
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function iconFor(type: Notification["type"]): string {
|
|
43
|
+
switch (type) {
|
|
44
|
+
case "info": return "ℹ";
|
|
45
|
+
case "warning": return "⚠";
|
|
46
|
+
case "rate-limit": return "🚫";
|
|
47
|
+
case "watch-change": return "🔔";
|
|
48
|
+
case "upgrade": return "📦";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getNotifications(): Notification[] {
|
|
53
|
+
if (notifs.length === 0) load();
|
|
54
|
+
return [...notifs];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function dismissNotification(id: number): void {
|
|
58
|
+
notifs = notifs.filter((n) => n.id !== id);
|
|
59
|
+
save();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function dismissAll(): void {
|
|
63
|
+
notifs = [];
|
|
64
|
+
save();
|
|
65
|
+
}
|
package/src/open-url.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser — platform-aware.
|
|
3
|
+
*
|
|
4
|
+
* only if the open/xdg-open pattern fails somewhere.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function openUrl(url: string): void {
|
|
8
|
+
try {
|
|
9
|
+
const p = process.platform;
|
|
10
|
+
if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
11
|
+
else if (p === "darwin") Bun.spawn(["open", url]);
|
|
12
|
+
else Bun.spawn(["xdg-open", url]);
|
|
13
|
+
} catch {
|
|
14
|
+
// non-critical — user can copy the URL manually
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -1,74 +1,68 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
lines.
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
lines.push("
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const header = `${index}. ${repo.fullName}${tagStr}`;
|
|
70
|
-
const meta = ` ★ ${stars} • ${lang} • updated ${updated}`;
|
|
71
|
-
const desc = repo.description ? ` ${repo.description}` : " (no description)";
|
|
72
|
-
const url = ` ${repo.url}`;
|
|
73
|
-
return [header, meta, desc, url].join("\n");
|
|
1
|
+
/** Output layer: render ranked results as JSON or human-readable list. */
|
|
2
|
+
import type { Repo, SearchResponse } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Format a number compactly (e.g. 1234 -> "1.2k", 999999 -> "1M"). */
|
|
5
|
+
export function formatCount(n: number): string {
|
|
6
|
+
if (n < 1000) return String(n);
|
|
7
|
+
if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
|
|
8
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
9
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatDate(iso: string): string {
|
|
13
|
+
const d = new Date(iso);
|
|
14
|
+
if (isNaN(d.getTime())) return "unknown";
|
|
15
|
+
return d.toISOString().slice(0, 10);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderJson(response: SearchResponse, rawQuery: string): string {
|
|
19
|
+
return JSON.stringify(
|
|
20
|
+
{
|
|
21
|
+
query: rawQuery,
|
|
22
|
+
totalCount: response.totalCount,
|
|
23
|
+
count: response.repos.length,
|
|
24
|
+
rateLimited: response.rateLimited,
|
|
25
|
+
results: response.repos,
|
|
26
|
+
},
|
|
27
|
+
null,
|
|
28
|
+
2,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderHuman(response: SearchResponse, rawQuery: string): string {
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
lines.push(`Query: ${rawQuery}`);
|
|
35
|
+
lines.push(`Matches: ${response.repos.length} (total available: ${response.totalCount})`);
|
|
36
|
+
if (response.rateLimited) {
|
|
37
|
+
lines.push("⚠ Rate limit hit — results may be incomplete. Set GITHUB_TOKEN to raise limits.");
|
|
38
|
+
}
|
|
39
|
+
lines.push("");
|
|
40
|
+
|
|
41
|
+
if (response.repos.length === 0) {
|
|
42
|
+
lines.push("No repositories found.");
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
response.repos.forEach((repo, i) => {
|
|
47
|
+
lines.push(renderRepo(i + 1, repo));
|
|
48
|
+
lines.push("");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return lines.join("\n").trimEnd();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderRepo(index: number, repo: Repo): string {
|
|
55
|
+
const lang = repo.language ?? "—";
|
|
56
|
+
const stars = formatCount(repo.stars);
|
|
57
|
+
const updated = formatDate(repo.updatedAt);
|
|
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");
|
|
74
68
|
}
|
package/src/pipe.ts
CHANGED
|
@@ -1,65 +1,54 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pipe-friendly format modifiers — output repo data as lines for shell piping.
|
|
3
|
-
*/
|
|
4
|
-
import type { Repo } from "./types
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export function formatUrls(repos: Repo[]): string {
|
|
10
|
-
return repos.map((r) => r.url).join("\n");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export function
|
|
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
|
-
async function openUrl(url: string): Promise<void> {
|
|
60
|
-
const p = process.platform;
|
|
61
|
-
const args = p === "win32" ? ["cmd", "/c", "start", "", url]
|
|
62
|
-
: p === "darwin" ? ["open", url]
|
|
63
|
-
: ["xdg-open", url];
|
|
64
|
-
Bun.spawn(args);
|
|
65
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Pipe-friendly format modifiers — output repo data as lines for shell piping.
|
|
3
|
+
*/
|
|
4
|
+
import type { Repo } from "./types";
|
|
5
|
+
import { openUrl } from "./open-url";
|
|
6
|
+
|
|
7
|
+
export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
8
|
+
|
|
9
|
+
export function formatUrls(repos: Repo[]): string {
|
|
10
|
+
return repos.map((r) => r.url).join("\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatNames(repos: Repo[]): string {
|
|
14
|
+
return repos.map((r) => r.fullName).join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatSshUrls(repos: Repo[]): string {
|
|
18
|
+
return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatCloneCommands(repos: Repo[]): string {
|
|
22
|
+
return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatIds(repos: Repo[]): string {
|
|
26
|
+
return repos.map((r) => String(r.id)).join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatLines(repos: Repo[], format: FormatLine): string {
|
|
30
|
+
switch (format) {
|
|
31
|
+
case "urls": return formatUrls(repos);
|
|
32
|
+
case "names": return formatNames(repos);
|
|
33
|
+
case "ssh-urls": return formatSshUrls(repos);
|
|
34
|
+
case "clone-commands": return formatCloneCommands(repos);
|
|
35
|
+
case "ids": return formatIds(repos);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Execute a pipe target (clone, open) on repos. */
|
|
40
|
+
export async function pipeExec(repos: Repo[], target: string): Promise<void> {
|
|
41
|
+
for (const repo of repos) {
|
|
42
|
+
const url = repo.url;
|
|
43
|
+
switch (target) {
|
|
44
|
+
case "open":
|
|
45
|
+
await openUrl(url);
|
|
46
|
+
break;
|
|
47
|
+
case "clone":
|
|
48
|
+
console.log(`git clone git@github.com:${repo.fullName}.git`);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
package/src/saved-searches.ts
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Saved searches — named queries you can re-run anytime.
|
|
3
|
-
*/
|
|
4
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
|
-
import type { SavedSearch, SortStrategy } from "./types
|
|
7
|
-
import { stateDir } from "./config
|
|
8
|
-
|
|
9
|
-
const SAVED_FILE = join(stateDir(), "saved-searches.json");
|
|
10
|
-
|
|
11
|
-
function loadRaw(): SavedSearch[] {
|
|
12
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
13
|
-
try {
|
|
14
|
-
return JSON.parse(readFileSync(SAVED_FILE, "utf-8")) as SavedSearch[];
|
|
15
|
-
} catch {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function saveRaw(searches: SavedSearch[]): void {
|
|
21
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
22
|
-
writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Get all saved searches, sorted by last run time (most recent first). */
|
|
26
|
-
export function getSavedSearches(): SavedSearch[] {
|
|
27
|
-
return loadRaw().sort((a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Save a new search. Overwrites if name already exists. */
|
|
31
|
-
export function saveSearch(name: string, query: string, mode: "search" | "trending", sort: SortStrategy, limit: number, tab?: string): SavedSearch {
|
|
32
|
-
const searches = loadRaw();
|
|
33
|
-
const now = Date.now();
|
|
34
|
-
const existing = searches.findIndex((s) => s.name === name);
|
|
35
|
-
const entry: SavedSearch = { name, query, mode, sort, limit, tab, createdAt: now, lastRunAt: now };
|
|
36
|
-
if (existing >= 0) {
|
|
37
|
-
searches[existing] = { ...searches[existing], ...entry, createdAt: searches[existing].createdAt };
|
|
38
|
-
} else {
|
|
39
|
-
searches.push(entry);
|
|
40
|
-
}
|
|
41
|
-
saveRaw(searches);
|
|
42
|
-
return entry;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Delete a saved search by name. */
|
|
46
|
-
export function deleteSavedSearch(name: string): void {
|
|
47
|
-
saveRaw(loadRaw().filter((s) => s.name !== name));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Update lastRunAt for a saved search. */
|
|
51
|
-
export function touchSavedSearch(name: string): void {
|
|
52
|
-
const searches = loadRaw();
|
|
53
|
-
const found = searches.find((s) => s.name === name);
|
|
54
|
-
if (found) {
|
|
55
|
-
found.lastRunAt = Date.now();
|
|
56
|
-
saveRaw(searches);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Saved searches — named queries you can re-run anytime.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import type { SavedSearch, SortStrategy } from "./types";
|
|
7
|
+
import { stateDir } from "./config";
|
|
8
|
+
|
|
9
|
+
const SAVED_FILE = join(stateDir(), "saved-searches.json");
|
|
10
|
+
|
|
11
|
+
function loadRaw(): SavedSearch[] {
|
|
12
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(SAVED_FILE, "utf-8")) as SavedSearch[];
|
|
15
|
+
} catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function saveRaw(searches: SavedSearch[]): void {
|
|
21
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
22
|
+
writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Get all saved searches, sorted by last run time (most recent first). */
|
|
26
|
+
export function getSavedSearches(): SavedSearch[] {
|
|
27
|
+
return loadRaw().sort((a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Save a new search. Overwrites if name already exists. */
|
|
31
|
+
export function saveSearch(name: string, query: string, mode: "search" | "trending", sort: SortStrategy, limit: number, tab?: string): SavedSearch {
|
|
32
|
+
const searches = loadRaw();
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const existing = searches.findIndex((s) => s.name === name);
|
|
35
|
+
const entry: SavedSearch = { name, query, mode, sort, limit, tab, createdAt: now, lastRunAt: now };
|
|
36
|
+
if (existing >= 0) {
|
|
37
|
+
searches[existing] = { ...searches[existing], ...entry, createdAt: searches[existing].createdAt };
|
|
38
|
+
} else {
|
|
39
|
+
searches.push(entry);
|
|
40
|
+
}
|
|
41
|
+
saveRaw(searches);
|
|
42
|
+
return entry;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Delete a saved search by name. */
|
|
46
|
+
export function deleteSavedSearch(name: string): void {
|
|
47
|
+
saveRaw(loadRaw().filter((s) => s.name !== name));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Update lastRunAt for a saved search. */
|
|
51
|
+
export function touchSavedSearch(name: string): void {
|
|
52
|
+
const searches = loadRaw();
|
|
53
|
+
const found = searches.find((s) => s.name === name);
|
|
54
|
+
if (found) {
|
|
55
|
+
found.lastRunAt = Date.now();
|
|
56
|
+
saveRaw(searches);
|
|
57
|
+
}
|
|
58
|
+
}
|