github-search-cli 9.4.1 → 9.4.3
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/CHANGELOG.md +65 -1
- package/README.md +268 -237
- package/bin/ghfind.js +98 -89
- package/completions/ghfind.bash +1 -1
- package/completions/ghfind.fish +3 -1
- package/completions/ghfind.zsh +6 -0
- package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
- package/dist/cli.js +1179 -403
- package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
- package/package.json +16 -5
- package/release-notes/9.3.0.md +32 -29
- package/scripts/build.ts +34 -19
- package/scripts/generate-svg-demo.js +218 -168
- package/scripts/postinstall.js +176 -176
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +650 -566
- package/src/compare.ts +69 -47
- package/src/config.ts +70 -65
- package/src/deepdive.ts +149 -118
- package/src/doctor.ts +287 -287
- package/src/error-report.ts +8 -12
- package/src/errors.ts +62 -59
- package/src/explore.ts +34 -29
- package/src/export.ts +73 -63
- package/src/help.ts +157 -157
- package/src/history.ts +69 -55
- package/src/init.ts +60 -55
- package/src/landing.ts +106 -104
- package/src/login.ts +186 -0
- package/src/markdown-render.ts +1 -6
- package/src/notifications.ts +75 -59
- package/src/open-url.ts +16 -16
- package/src/org.ts +299 -0
- package/src/output.ts +153 -137
- package/src/package-browser.ts +317 -305
- package/src/package.ts +149 -137
- package/src/pipe.ts +60 -54
- package/src/releases.ts +182 -144
- package/src/saved-searches.ts +72 -50
- package/src/search-state.ts +283 -229
- package/src/search.ts +678 -653
- package/src/session.ts +26 -24
- package/src/share.ts +61 -54
- package/src/status.ts +68 -61
- package/src/storage.ts +136 -106
- package/src/themes.ts +259 -116
- package/src/tips.ts +42 -42
- package/src/toast.ts +70 -69
- package/src/trending-parser.ts +104 -0
- package/src/trending.ts +481 -462
- package/src/tui.ts +3015 -2867
- package/src/types.ts +160 -158
- package/src/update-check.ts +231 -215
- package/src/version.ts +33 -0
- package/src/watch.ts +95 -74
package/scripts/postinstall.js
CHANGED
|
@@ -1,176 +1,176 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Postinstall script — downloads a Bun binary for ghfind's TUI.
|
|
4
|
-
*
|
|
5
|
-
* If a runnable vendor/bun or vendor/bun.exe already exists, this is a no-op.
|
|
6
|
-
* Otherwise, it downloads the correct Bun binary for the user's platform
|
|
7
|
-
* (including the right libc flavor for Linux: glibc vs musl).
|
|
8
|
-
* On failure, prints a warning and exits 0 so npm install still succeeds.
|
|
9
|
-
*/
|
|
10
|
-
import {
|
|
11
|
-
existsSync,
|
|
12
|
-
mkdirSync,
|
|
13
|
-
chmodSync,
|
|
14
|
-
writeFileSync,
|
|
15
|
-
readFileSync,
|
|
16
|
-
} from "fs";
|
|
17
|
-
import { join, dirname } from "path";
|
|
18
|
-
import { fileURLToPath } from "url";
|
|
19
|
-
import { platform, arch } from "os";
|
|
20
|
-
import { spawnSync } from "child_process";
|
|
21
|
-
import { inflateRawSync } from "zlib";
|
|
22
|
-
|
|
23
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
const ROOT = join(__dirname, "..");
|
|
25
|
-
const VENDOR_DIR = join(ROOT, "vendor");
|
|
26
|
-
|
|
27
|
-
const bunName = platform() === "win32" ? "bun.exe" : "bun";
|
|
28
|
-
const bunPath = join(VENDOR_DIR, bunName);
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Detect whether the current Linux system uses musl or glibc.
|
|
32
|
-
* Bun ships separate builds for each; musl binaries cannot run on glibc
|
|
33
|
-
* systems and vice versa.
|
|
34
|
-
*/
|
|
35
|
-
function libcFlavor() {
|
|
36
|
-
if (platform() !== "linux") return "glibc";
|
|
37
|
-
// Check /etc/os-release first (fast, no subprocess)
|
|
38
|
-
try {
|
|
39
|
-
const osRelease = readFileSync("/etc/os-release", "utf8");
|
|
40
|
-
if (/alpine|musl/i.test(osRelease)) return "musl";
|
|
41
|
-
} catch {
|
|
42
|
-
// ignore
|
|
43
|
-
}
|
|
44
|
-
// Fall back to ldd --version output
|
|
45
|
-
try {
|
|
46
|
-
const ldd = spawnSync("ldd", ["--version"], { encoding: "utf8" });
|
|
47
|
-
const out = `${ldd.stdout}${ldd.stderr}`;
|
|
48
|
-
if (/musl/i.test(out)) return "musl";
|
|
49
|
-
if (/glibc|GNU libc/i.test(out)) return "glibc";
|
|
50
|
-
} catch {
|
|
51
|
-
// ignore
|
|
52
|
-
}
|
|
53
|
-
return "glibc";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Skip in CI/test environments
|
|
57
|
-
if (process.env.GHFIND_SKIP_BUN === "1" || process.env.CI === "true") {
|
|
58
|
-
process.exit(0);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Returns true if an existing Bun binary is actually runnable.
|
|
63
|
-
* A binary of the wrong libc flavor (e.g. musl build on a glibc system)
|
|
64
|
-
* fails to launch with ENOENT because its dynamic loader is missing.
|
|
65
|
-
*/
|
|
66
|
-
function binaryRuns(path) {
|
|
67
|
-
try {
|
|
68
|
-
const res = spawnSync(path, ["--version"], { encoding: "utf8" });
|
|
69
|
-
return res.status === 0;
|
|
70
|
-
} catch {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Already bundled and runnable — nothing to do
|
|
76
|
-
if (existsSync(bunPath) && binaryRuns(bunPath)) {
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Try to download
|
|
81
|
-
const linuxLibc = libcFlavor();
|
|
82
|
-
const map = {
|
|
83
|
-
"win32-x64": "bun-windows-x64.zip",
|
|
84
|
-
"win32-arm64": "bun-windows-aarch64.zip",
|
|
85
|
-
"darwin-x64": "bun-darwin-x64.zip",
|
|
86
|
-
"darwin-arm64": "bun-darwin-aarch64.zip",
|
|
87
|
-
"linux-x64":
|
|
88
|
-
linuxLibc === "musl" ? "bun-linux-x64-musl.zip" : "bun-linux-x64.zip",
|
|
89
|
-
"linux-arm64":
|
|
90
|
-
linuxLibc === "musl"
|
|
91
|
-
? "bun-linux-aarch64-musl.zip"
|
|
92
|
-
: "bun-linux-aarch64.zip",
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const key = `${platform()}-${arch}`;
|
|
96
|
-
const assetName = map[key];
|
|
97
|
-
const version = "1.3.14";
|
|
98
|
-
|
|
99
|
-
if (!assetName) {
|
|
100
|
-
console.error(
|
|
101
|
-
`[ghfind] Unsupported platform: ${key}. Install Bun manually: https://bun.sh`,
|
|
102
|
-
);
|
|
103
|
-
process.exit(0);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const url = `https://github.com/oven-sh/bun/releases/download/bun-v${version}/${assetName}`;
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
mkdirSync(VENDOR_DIR, { recursive: true });
|
|
110
|
-
console.error(
|
|
111
|
-
`[ghfind] Downloading Bun ${version} for ${key}${linuxLibc === "musl" ? " (musl)" : ""}...`,
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const res = await fetch(url);
|
|
115
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
116
|
-
|
|
117
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
118
|
-
|
|
119
|
-
// Find the central directory file header signature (PK\x01\x02)
|
|
120
|
-
const cdSig = "PK\x01\x02";
|
|
121
|
-
let cdOffset = buffer.indexOf(cdSig, 0);
|
|
122
|
-
while (cdOffset !== -1) {
|
|
123
|
-
const nameLen = buffer.readUInt16LE(cdOffset + 28);
|
|
124
|
-
const extraLen = buffer.readUInt16LE(cdOffset + 30);
|
|
125
|
-
const commentLen = buffer.readUInt16LE(cdOffset + 32);
|
|
126
|
-
const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42);
|
|
127
|
-
const compSize = buffer.readUInt32LE(cdOffset + 20);
|
|
128
|
-
const compMethod = buffer.readUInt16LE(cdOffset + 10);
|
|
129
|
-
const nameStart = cdOffset + 46;
|
|
130
|
-
const fileName = buffer
|
|
131
|
-
.subarray(nameStart, nameStart + nameLen)
|
|
132
|
-
.toString("utf8");
|
|
133
|
-
|
|
134
|
-
if (fileName.endsWith(bunName)) {
|
|
135
|
-
// Read the local file header to get the actual data offset
|
|
136
|
-
const localSig = buffer.readUInt32LE(localHeaderOffset);
|
|
137
|
-
if (localSig !== 0x04034b50) throw new Error("Invalid local file header");
|
|
138
|
-
const localNameLen = buffer.readUInt16LE(localHeaderOffset + 26);
|
|
139
|
-
const localExtraLen = buffer.readUInt16LE(localHeaderOffset + 28);
|
|
140
|
-
const dataOffset = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
|
141
|
-
|
|
142
|
-
let fileData;
|
|
143
|
-
if (compMethod === 0) {
|
|
144
|
-
// Stored (no compression)
|
|
145
|
-
fileData = buffer.subarray(dataOffset, dataOffset + compSize);
|
|
146
|
-
} else {
|
|
147
|
-
// Deflated
|
|
148
|
-
fileData = inflateRawSync(
|
|
149
|
-
buffer.subarray(dataOffset, dataOffset + compSize),
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
writeFileSync(bunPath, fileData);
|
|
154
|
-
if (platform() !== "win32") chmodSync(bunPath, 0o755);
|
|
155
|
-
console.error(`[ghfind] Bun installed to vendor/${bunName}`);
|
|
156
|
-
process.exit(0);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
cdOffset = buffer.indexOf(cdSig, cdOffset + 4);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
throw new Error("Bun binary not found in archive");
|
|
163
|
-
} catch (err) {
|
|
164
|
-
console.warn(
|
|
165
|
-
`[ghfind] ⚠ Could not download Bun: ${err instanceof Error ? err.message : String(err)}`,
|
|
166
|
-
);
|
|
167
|
-
console.warn(
|
|
168
|
-
"[ghfind] The interactive TUI requires Bun. Install it manually:",
|
|
169
|
-
);
|
|
170
|
-
console.warn("[ghfind] curl -fsSL https://bun.sh/install | bash");
|
|
171
|
-
console.warn("[ghfind] # or: npm install -g bun");
|
|
172
|
-
console.warn(
|
|
173
|
-
"[ghfind] Non-interactive modes (--json, --csv, etc.) will still work with Node.js 20+.",
|
|
174
|
-
);
|
|
175
|
-
process.exit(0);
|
|
176
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall script — downloads a Bun binary for ghfind's TUI.
|
|
4
|
+
*
|
|
5
|
+
* If a runnable vendor/bun or vendor/bun.exe already exists, this is a no-op.
|
|
6
|
+
* Otherwise, it downloads the correct Bun binary for the user's platform
|
|
7
|
+
* (including the right libc flavor for Linux: glibc vs musl).
|
|
8
|
+
* On failure, prints a warning and exits 0 so npm install still succeeds.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
chmodSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
} from "fs";
|
|
17
|
+
import { join, dirname } from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
import { platform, arch } from "os";
|
|
20
|
+
import { spawnSync } from "child_process";
|
|
21
|
+
import { inflateRawSync } from "zlib";
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const ROOT = join(__dirname, "..");
|
|
25
|
+
const VENDOR_DIR = join(ROOT, "vendor");
|
|
26
|
+
|
|
27
|
+
const bunName = platform() === "win32" ? "bun.exe" : "bun";
|
|
28
|
+
const bunPath = join(VENDOR_DIR, bunName);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect whether the current Linux system uses musl or glibc.
|
|
32
|
+
* Bun ships separate builds for each; musl binaries cannot run on glibc
|
|
33
|
+
* systems and vice versa.
|
|
34
|
+
*/
|
|
35
|
+
function libcFlavor() {
|
|
36
|
+
if (platform() !== "linux") return "glibc";
|
|
37
|
+
// Check /etc/os-release first (fast, no subprocess)
|
|
38
|
+
try {
|
|
39
|
+
const osRelease = readFileSync("/etc/os-release", "utf8");
|
|
40
|
+
if (/alpine|musl/i.test(osRelease)) return "musl";
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
// Fall back to ldd --version output
|
|
45
|
+
try {
|
|
46
|
+
const ldd = spawnSync("ldd", ["--version"], { encoding: "utf8" });
|
|
47
|
+
const out = `${ldd.stdout}${ldd.stderr}`;
|
|
48
|
+
if (/musl/i.test(out)) return "musl";
|
|
49
|
+
if (/glibc|GNU libc/i.test(out)) return "glibc";
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
return "glibc";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Skip in CI/test environments
|
|
57
|
+
if (process.env.GHFIND_SKIP_BUN === "1" || process.env.CI === "true") {
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns true if an existing Bun binary is actually runnable.
|
|
63
|
+
* A binary of the wrong libc flavor (e.g. musl build on a glibc system)
|
|
64
|
+
* fails to launch with ENOENT because its dynamic loader is missing.
|
|
65
|
+
*/
|
|
66
|
+
function binaryRuns(path) {
|
|
67
|
+
try {
|
|
68
|
+
const res = spawnSync(path, ["--version"], { encoding: "utf8" });
|
|
69
|
+
return res.status === 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Already bundled and runnable — nothing to do
|
|
76
|
+
if (existsSync(bunPath) && binaryRuns(bunPath)) {
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Try to download
|
|
81
|
+
const linuxLibc = libcFlavor();
|
|
82
|
+
const map = {
|
|
83
|
+
"win32-x64": "bun-windows-x64.zip",
|
|
84
|
+
"win32-arm64": "bun-windows-aarch64.zip",
|
|
85
|
+
"darwin-x64": "bun-darwin-x64.zip",
|
|
86
|
+
"darwin-arm64": "bun-darwin-aarch64.zip",
|
|
87
|
+
"linux-x64":
|
|
88
|
+
linuxLibc === "musl" ? "bun-linux-x64-musl.zip" : "bun-linux-x64.zip",
|
|
89
|
+
"linux-arm64":
|
|
90
|
+
linuxLibc === "musl"
|
|
91
|
+
? "bun-linux-aarch64-musl.zip"
|
|
92
|
+
: "bun-linux-aarch64.zip",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const key = `${platform()}-${arch}`;
|
|
96
|
+
const assetName = map[key];
|
|
97
|
+
const version = "1.3.14";
|
|
98
|
+
|
|
99
|
+
if (!assetName) {
|
|
100
|
+
console.error(
|
|
101
|
+
`[ghfind] Unsupported platform: ${key}. Install Bun manually: https://bun.sh`,
|
|
102
|
+
);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const url = `https://github.com/oven-sh/bun/releases/download/bun-v${version}/${assetName}`;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
mkdirSync(VENDOR_DIR, { recursive: true });
|
|
110
|
+
console.error(
|
|
111
|
+
`[ghfind] Downloading Bun ${version} for ${key}${linuxLibc === "musl" ? " (musl)" : ""}...`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const res = await fetch(url);
|
|
115
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
116
|
+
|
|
117
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
118
|
+
|
|
119
|
+
// Find the central directory file header signature (PK\x01\x02)
|
|
120
|
+
const cdSig = "PK\x01\x02";
|
|
121
|
+
let cdOffset = buffer.indexOf(cdSig, 0);
|
|
122
|
+
while (cdOffset !== -1) {
|
|
123
|
+
const nameLen = buffer.readUInt16LE(cdOffset + 28);
|
|
124
|
+
const extraLen = buffer.readUInt16LE(cdOffset + 30);
|
|
125
|
+
const commentLen = buffer.readUInt16LE(cdOffset + 32);
|
|
126
|
+
const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42);
|
|
127
|
+
const compSize = buffer.readUInt32LE(cdOffset + 20);
|
|
128
|
+
const compMethod = buffer.readUInt16LE(cdOffset + 10);
|
|
129
|
+
const nameStart = cdOffset + 46;
|
|
130
|
+
const fileName = buffer
|
|
131
|
+
.subarray(nameStart, nameStart + nameLen)
|
|
132
|
+
.toString("utf8");
|
|
133
|
+
|
|
134
|
+
if (fileName.endsWith(bunName)) {
|
|
135
|
+
// Read the local file header to get the actual data offset
|
|
136
|
+
const localSig = buffer.readUInt32LE(localHeaderOffset);
|
|
137
|
+
if (localSig !== 0x04034b50) throw new Error("Invalid local file header");
|
|
138
|
+
const localNameLen = buffer.readUInt16LE(localHeaderOffset + 26);
|
|
139
|
+
const localExtraLen = buffer.readUInt16LE(localHeaderOffset + 28);
|
|
140
|
+
const dataOffset = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
|
141
|
+
|
|
142
|
+
let fileData;
|
|
143
|
+
if (compMethod === 0) {
|
|
144
|
+
// Stored (no compression)
|
|
145
|
+
fileData = buffer.subarray(dataOffset, dataOffset + compSize);
|
|
146
|
+
} else {
|
|
147
|
+
// Deflated
|
|
148
|
+
fileData = inflateRawSync(
|
|
149
|
+
buffer.subarray(dataOffset, dataOffset + compSize),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
writeFileSync(bunPath, fileData);
|
|
154
|
+
if (platform() !== "win32") chmodSync(bunPath, 0o755);
|
|
155
|
+
console.error(`[ghfind] Bun installed to vendor/${bunName}`);
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
cdOffset = buffer.indexOf(cdSig, cdOffset + 4);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
throw new Error("Bun binary not found in archive");
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn(
|
|
165
|
+
`[ghfind] ⚠ Could not download Bun: ${err instanceof Error ? err.message : String(err)}`,
|
|
166
|
+
);
|
|
167
|
+
console.warn(
|
|
168
|
+
"[ghfind] The interactive TUI requires Bun. Install it manually:",
|
|
169
|
+
);
|
|
170
|
+
console.warn("[ghfind] curl -fsSL https://bun.sh/install | bash");
|
|
171
|
+
console.warn("[ghfind] # or: npm install -g bun");
|
|
172
|
+
console.warn(
|
|
173
|
+
"[ghfind] Non-interactive modes (--json, --csv, etc.) will still work with Node.js 20+.",
|
|
174
|
+
);
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
package/src/bookmarks.ts
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bookmarks — saved repos with tags, persisted as JSON.
|
|
3
|
-
*/
|
|
4
|
-
import type { Bookmark, Repo } from "./types";
|
|
5
|
-
import { readJSON, writeJSON } from "./storage";
|
|
6
|
-
|
|
7
|
-
const BOOKMARK_FILE = "bookmarks.json";
|
|
8
|
-
|
|
9
|
-
function loadRaw(): Bookmark[] {
|
|
10
|
-
return readJSON<Bookmark[]>(BOOKMARK_FILE, []);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function saveRaw(bookmarks: Bookmark[]): void {
|
|
14
|
-
writeJSON(BOOKMARK_FILE, bookmarks);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Get all bookmarks, newest first. */
|
|
18
|
-
export function getBookmarks(): Bookmark[] {
|
|
19
|
-
return loadRaw().sort((a, b) => b.savedAt - a.savedAt);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Check if a repo is bookmarked by fullName. */
|
|
23
|
-
export function isBookmarked(fullName: string): boolean {
|
|
24
|
-
return loadRaw().some((b) => b.repo.fullName === fullName);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Toggle bookmark: add if not present, remove if present. Returns new state. */
|
|
28
|
-
export function toggleBookmark(repo: Repo, tags: string[] = []): boolean {
|
|
29
|
-
const bookmarks = loadRaw();
|
|
30
|
-
const idx = bookmarks.findIndex((b) => b.repo.fullName === repo.fullName);
|
|
31
|
-
if (idx >= 0) {
|
|
32
|
-
bookmarks.splice(idx, 1);
|
|
33
|
-
saveRaw(bookmarks);
|
|
34
|
-
return false; // removed
|
|
35
|
-
}
|
|
36
|
-
bookmarks.push({ repo, savedAt: Date.now(), tags });
|
|
37
|
-
saveRaw(bookmarks);
|
|
38
|
-
return true; // added
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Remove a bookmark by fullName. */
|
|
42
|
-
export function removeBookmark(fullName: string): void {
|
|
43
|
-
const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
|
|
44
|
-
saveRaw(bookmarks);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Search bookmarks by text (matches name, description, tags). */
|
|
48
|
-
export function searchBookmarks(query: string): Bookmark[] {
|
|
49
|
-
const q = query.toLowerCase();
|
|
50
|
-
return getBookmarks().filter(
|
|
51
|
-
(b) =>
|
|
52
|
-
b.repo.fullName.toLowerCase().includes(q) ||
|
|
53
|
-
(b.repo.description ?? "").toLowerCase().includes(q) ||
|
|
54
|
-
b.tags.some((t) => t.toLowerCase().includes(q)),
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
/** Read a repo's seen-marker (0 = never seen). */
|
|
58
|
-
export function getLastSeen(fullName: string): number {
|
|
59
|
-
return loadRaw().find((b) => b.repo.fullName === fullName)?.lastSeenAt ?? 0;
|
|
60
|
-
}
|
|
61
|
-
/** Mark a repo's releases as seen up to now. */
|
|
62
|
-
export function markSeen(fullName: string): void {
|
|
63
|
-
const bookmarks = loadRaw();
|
|
64
|
-
const bm = bookmarks.find((b) => b.repo.fullName === fullName);
|
|
65
|
-
if (bm) {
|
|
66
|
-
bm.lastSeenAt = Date.now();
|
|
67
|
-
saveRaw(bookmarks);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Bookmarks — saved repos with tags, persisted as JSON.
|
|
3
|
+
*/
|
|
4
|
+
import type { Bookmark, Repo } from "./types";
|
|
5
|
+
import { readJSON, writeJSON } from "./storage";
|
|
6
|
+
|
|
7
|
+
const BOOKMARK_FILE = "bookmarks.json";
|
|
8
|
+
|
|
9
|
+
function loadRaw(): Bookmark[] {
|
|
10
|
+
return readJSON<Bookmark[]>(BOOKMARK_FILE, []);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function saveRaw(bookmarks: Bookmark[]): void {
|
|
14
|
+
writeJSON(BOOKMARK_FILE, bookmarks);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Get all bookmarks, newest first. */
|
|
18
|
+
export function getBookmarks(): Bookmark[] {
|
|
19
|
+
return loadRaw().sort((a, b) => b.savedAt - a.savedAt);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Check if a repo is bookmarked by fullName. */
|
|
23
|
+
export function isBookmarked(fullName: string): boolean {
|
|
24
|
+
return loadRaw().some((b) => b.repo.fullName === fullName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Toggle bookmark: add if not present, remove if present. Returns new state. */
|
|
28
|
+
export function toggleBookmark(repo: Repo, tags: string[] = []): boolean {
|
|
29
|
+
const bookmarks = loadRaw();
|
|
30
|
+
const idx = bookmarks.findIndex((b) => b.repo.fullName === repo.fullName);
|
|
31
|
+
if (idx >= 0) {
|
|
32
|
+
bookmarks.splice(idx, 1);
|
|
33
|
+
saveRaw(bookmarks);
|
|
34
|
+
return false; // removed
|
|
35
|
+
}
|
|
36
|
+
bookmarks.push({ repo, savedAt: Date.now(), tags });
|
|
37
|
+
saveRaw(bookmarks);
|
|
38
|
+
return true; // added
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Remove a bookmark by fullName. */
|
|
42
|
+
export function removeBookmark(fullName: string): void {
|
|
43
|
+
const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
|
|
44
|
+
saveRaw(bookmarks);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Search bookmarks by text (matches name, description, tags). */
|
|
48
|
+
export function searchBookmarks(query: string): Bookmark[] {
|
|
49
|
+
const q = query.toLowerCase();
|
|
50
|
+
return getBookmarks().filter(
|
|
51
|
+
(b) =>
|
|
52
|
+
b.repo.fullName.toLowerCase().includes(q) ||
|
|
53
|
+
(b.repo.description ?? "").toLowerCase().includes(q) ||
|
|
54
|
+
b.tags.some((t) => t.toLowerCase().includes(q)),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
/** Read a repo's seen-marker (0 = never seen). */
|
|
58
|
+
export function getLastSeen(fullName: string): number {
|
|
59
|
+
return loadRaw().find((b) => b.repo.fullName === fullName)?.lastSeenAt ?? 0;
|
|
60
|
+
}
|
|
61
|
+
/** Mark a repo's releases as seen up to now. */
|
|
62
|
+
export function markSeen(fullName: string): void {
|
|
63
|
+
const bookmarks = loadRaw();
|
|
64
|
+
const bm = bookmarks.find((b) => b.repo.fullName === fullName);
|
|
65
|
+
if (bm) {
|
|
66
|
+
bm.lastSeenAt = Date.now();
|
|
67
|
+
saveRaw(bookmarks);
|
|
68
|
+
}
|
|
69
|
+
}
|