github-search-cli 0.6.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/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/search-cli.js +17 -0
- package/completions/search-cli.bash +43 -0
- package/completions/search-cli.fish +19 -0
- package/completions/search-cli.zsh +27 -0
- package/package.json +55 -0
- package/src/bookmarks.ts +69 -0
- package/src/cache.ts +64 -0
- package/src/cli.ts +242 -0
- package/src/compare.ts +46 -0
- package/src/config.ts +66 -0
- package/src/deepdive.ts +124 -0
- package/src/errors.ts +62 -0
- package/src/explore.ts +45 -0
- package/src/export.ts +56 -0
- package/src/help.ts +46 -0
- package/src/history.ts +91 -0
- package/src/init.ts +47 -0
- package/src/normalizer.ts +66 -0
- package/src/notifications.ts +69 -0
- package/src/output.ts +74 -0
- package/src/pipe.ts +65 -0
- package/src/provider.ts +142 -0
- package/src/query.ts +251 -0
- package/src/ranking.ts +55 -0
- package/src/saved-searches.ts +58 -0
- package/src/session.ts +35 -0
- package/src/share.ts +49 -0
- package/src/status.ts +61 -0
- package/src/themes.ts +52 -0
- package/src/tips.ts +27 -0
- package/src/trending.ts +588 -0
- package/src/tui.ts +1463 -0
- package/src/types.ts +148 -0
- package/src/watch.ts +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 search-cli contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# search-cli
|
|
2
|
+
|
|
3
|
+
> The terminal home for GitHub discovery — search, trending, bookmarks, and deep-dive repo exploration, all without touching a browser.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx github-search-cli # TUI browser
|
|
7
|
+
search-cli "language:Rust stars:>1000" --json # CLI mode
|
|
8
|
+
search-cli --version
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
| Category | Features |
|
|
16
|
+
|----------|----------|
|
|
17
|
+
| **Search** | Free-text + qualifiers (`language:`, `stars:`, `topic:`, etc.), sort by stars/updated/forks, auto-complete on Tab |
|
|
18
|
+
| **Trending** | Browse GitHub trending by day/week/month/year, language filter |
|
|
19
|
+
| **Memory** | Persistent search history (`Ctrl+R`), bookmarks (`b`/`B`), saved searches (`Ctrl+S`/`Ctrl+O`), session restore |
|
|
20
|
+
| **Deep-dive** | Per-repo detail: languages bar, top contributors, README preview (`d`) |
|
|
21
|
+
| **Compare** | Select 2+ repos and compare side-by-side (`c`/`C`) |
|
|
22
|
+
| **Explore** | Browse popular GitHub topics (`E`) |
|
|
23
|
+
| **Export** | JSON, CSV, Markdown, plain text (`Ctrl+E`) |
|
|
24
|
+
| **Shell** | Pipe-friendly `--json`/`--csv`/`--markdown`, `--format urls`, `--pipe open/clone`, `--watch`, shell completions |
|
|
25
|
+
| **Delight** | 3 themes (Tokyo Night, Dracula, Monokai), animated status bar, notification center, clipboard share, startup tips |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Requires Bun (https://bun.sh)
|
|
33
|
+
npm install -g github-search-cli
|
|
34
|
+
# or: bun install -g github-search-cli
|
|
35
|
+
|
|
36
|
+
# Launch the interactive TUI browser
|
|
37
|
+
search-cli
|
|
38
|
+
|
|
39
|
+
# Non-interactive search (pipe to jq, csvkit, etc.)
|
|
40
|
+
search-cli "language:Rust stars:>10000" --json | jq '.[].fullName'
|
|
41
|
+
search-cli "language:Zig" --count
|
|
42
|
+
search-cli --trending --json --since weekly
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## TUI keyboard shortcuts
|
|
48
|
+
|
|
49
|
+
### Main view
|
|
50
|
+
|
|
51
|
+
| Key | Action |
|
|
52
|
+
|-----|--------|
|
|
53
|
+
| `/` | Focus search input |
|
|
54
|
+
| `Enter` | Execute search / open repo |
|
|
55
|
+
| `↑`/`↓` / `j`/`k` | Navigate results |
|
|
56
|
+
| `t` | Toggle search / trending |
|
|
57
|
+
| `s` | Cycle sort |
|
|
58
|
+
| `l` | Cycle limit |
|
|
59
|
+
| `r` | Refresh |
|
|
60
|
+
| `g` | Toggle activity graph |
|
|
61
|
+
| `d` | Toggle repo deep-dive |
|
|
62
|
+
| `o` | Open repo in browser |
|
|
63
|
+
| `b` / `B` | Toggle bookmark / open bookmarks |
|
|
64
|
+
| `c` / `C` | Toggle compare list / show comparison |
|
|
65
|
+
| `E` | Open topic explorer |
|
|
66
|
+
| `Tab` | Auto-complete qualifier |
|
|
67
|
+
| `?` / `Ctrl+H` | Help overlay |
|
|
68
|
+
| `q` | Quit |
|
|
69
|
+
|
|
70
|
+
### Overlays
|
|
71
|
+
|
|
72
|
+
| Key | Action |
|
|
73
|
+
|-----|--------|
|
|
74
|
+
| `Ctrl+R` | Search history |
|
|
75
|
+
| `Ctrl+S` | Save current search |
|
|
76
|
+
| `Ctrl+O` | Saved searches panel |
|
|
77
|
+
| `Ctrl+E` | Export results |
|
|
78
|
+
| `Ctrl+N` | Notifications |
|
|
79
|
+
| `Ctrl+P` | Share repo (copy to clipboard) |
|
|
80
|
+
| `PageDown` / `Ctrl+F` | Next page |
|
|
81
|
+
| `Esc` | Close overlay |
|
|
82
|
+
|
|
83
|
+
### Trending mode
|
|
84
|
+
|
|
85
|
+
| Key | Action |
|
|
86
|
+
|-----|--------|
|
|
87
|
+
| `1`–`5` | Switch tab (Today → Week → Month → Year → All) |
|
|
88
|
+
| `←`/`→` / `h`/`l` | Navigate tabs |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Search syntax
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
language:Rust stars:>1000 topic:cli
|
|
96
|
+
"machine learning" language:Python stars:>5000
|
|
97
|
+
topic:cli -language:JavaScript
|
|
98
|
+
org:rust-lang language:Rust
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Supported qualifiers: `language:`, `stars:`, `fork:`, `archived:`, `topic:`, `user:`, `org:`, `repo:`, `updated:`, `pushed:`, `visibility:`, `license:`, `created:`, `size:`, `in:`. Prefix with `-` to exclude.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## CLI modes (non-interactive)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
search-cli "query" --json # JSON array
|
|
109
|
+
search-cli "query" --csv # CSV
|
|
110
|
+
search-cli "query" --markdown # Markdown table
|
|
111
|
+
search-cli "query" --count # Just the number
|
|
112
|
+
search-cli "query" --format urls # One URL per line
|
|
113
|
+
search-cli "query" --pipe open # Open each in browser
|
|
114
|
+
search-cli "query" --pipe clone # Print clone commands
|
|
115
|
+
search-cli --trending --json # Trending as JSON
|
|
116
|
+
search-cli --watch "query" # Poll every 300s
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Shell completions
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
source <(search-cli --completion bash) # Bash
|
|
123
|
+
source <(search-cli --completion zsh) # Zsh
|
|
124
|
+
search-cli --completion fish | source # Fish
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `gh` extension
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
gh extension install ./gh-search-cli
|
|
131
|
+
gh search-cli "language:Rust"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
`~/.config/search-cli/config.json` (created automatically):
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"defaultSort": "stars",
|
|
143
|
+
"defaultLimit": 50,
|
|
144
|
+
"theme": "tokyo-night",
|
|
145
|
+
"githubToken": "ghp_..."
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Or run `search-cli init` for the interactive setup wizard.
|
|
150
|
+
|
|
151
|
+
Environment variables: `GITHUB_TOKEN`, `SEARCH_CLI_CONFIG`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Design
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
CLI → QueryBuilder → Provider → Normalizer → Ranking → TUI / stdout
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Layer | File | Job |
|
|
162
|
+
|-------|------|-----|
|
|
163
|
+
| Entry | `src/cli.ts` | Arg parsing, routes to TUI or stdout |
|
|
164
|
+
| TUI | `src/tui.ts` | Interactive browser (OpenTUI) |
|
|
165
|
+
| Query | `src/query.ts` | Parse keywords/qualifiers |
|
|
166
|
+
| Provider | `src/provider.ts` | GitHub REST API with caching + token rotation |
|
|
167
|
+
| Types | `src/types.ts` | Shared interfaces |
|
|
168
|
+
|
|
169
|
+
The `SearchProvider` interface is the extension point for alternative backends (GitLab, local index, AI retriever).
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Install from source
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git clone https://github.com/codersdfs/search-cli.git
|
|
177
|
+
cd search-cli
|
|
178
|
+
bun install
|
|
179
|
+
bun start # Launch TUI
|
|
180
|
+
bun test # 104 tests
|
|
181
|
+
bun run build # Compile binary → dist/
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* search-cli — npm bin entry point.
|
|
4
|
+
* Requires Bun (https://bun.sh) to run.
|
|
5
|
+
*/
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cli = join(__dirname, "..", "src", "cli.ts");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
await import(cli);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error("Failed to start search-cli. Is Bun installed? (https://bun.sh)");
|
|
15
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# search-cli bash completion
|
|
2
|
+
# Source: source <(search-cli --completion bash)
|
|
3
|
+
|
|
4
|
+
_search_cli() {
|
|
5
|
+
local cur prev opts
|
|
6
|
+
COMPREPLY=()
|
|
7
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
8
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
9
|
+
|
|
10
|
+
opts="--json --csv --markdown --count --limit --sort --token --trending --since --pipe --format --watch --interval --version --help --completion"
|
|
11
|
+
|
|
12
|
+
case "${prev}" in
|
|
13
|
+
--sort)
|
|
14
|
+
COMPREPLY=( $(compgen -W "best-match stars updated forks" -- "${cur}") )
|
|
15
|
+
return 0
|
|
16
|
+
;;
|
|
17
|
+
--since)
|
|
18
|
+
COMPREPLY=( $(compgen -W "daily weekly monthly" -- "${cur}") )
|
|
19
|
+
return 0
|
|
20
|
+
;;
|
|
21
|
+
--pipe)
|
|
22
|
+
COMPREPLY=( $(compgen -W "clone open" -- "${cur}") )
|
|
23
|
+
return 0
|
|
24
|
+
;;
|
|
25
|
+
--format)
|
|
26
|
+
COMPREPLY=( $(compgen -W "urls names ssh-urls clone-commands ids" -- "${cur}") )
|
|
27
|
+
return 0
|
|
28
|
+
;;
|
|
29
|
+
--completion)
|
|
30
|
+
COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") )
|
|
31
|
+
return 0
|
|
32
|
+
;;
|
|
33
|
+
--limit|--interval)
|
|
34
|
+
return 0
|
|
35
|
+
;;
|
|
36
|
+
*)
|
|
37
|
+
;;
|
|
38
|
+
esac
|
|
39
|
+
|
|
40
|
+
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
|
41
|
+
return 0
|
|
42
|
+
}
|
|
43
|
+
complete -F _search_cli search-cli
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# search-cli fish completion
|
|
2
|
+
# Source: source <(search-cli --completion fish)
|
|
3
|
+
|
|
4
|
+
complete -c search-cli -l json -d "JSON output"
|
|
5
|
+
complete -c search-cli -l csv -d "CSV output"
|
|
6
|
+
complete -c search-cli -l markdown -d "Markdown table output"
|
|
7
|
+
complete -c search-cli -l count -d "Only result count"
|
|
8
|
+
complete -c search-cli -l limit -d "Max results" -r
|
|
9
|
+
complete -c search-cli -l sort -d "Sort strategy" -r -f -a "best-match stars updated forks"
|
|
10
|
+
complete -c search-cli -l token -d "GitHub token" -r
|
|
11
|
+
complete -c search-cli -l trending -d "Trending mode"
|
|
12
|
+
complete -c search-cli -l since -d "Trending period" -r -f -a "daily weekly monthly"
|
|
13
|
+
complete -c search-cli -l pipe -d "Pipe target" -r -f -a "clone open"
|
|
14
|
+
complete -c search-cli -l format -d "Output format" -r -f -a "urls names ssh-urls clone-commands ids"
|
|
15
|
+
complete -c search-cli -l watch -d "Watch mode"
|
|
16
|
+
complete -c search-cli -l interval -d "Watch interval seconds" -r
|
|
17
|
+
complete -c search-cli -l version -d "Print version"
|
|
18
|
+
complete -c search-cli -l help -d "Print help"
|
|
19
|
+
complete -c search-cli -l completion -d "Shell" -r -f -a "bash zsh fish"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# search-cli zsh completion
|
|
2
|
+
# Source: source <(search-cli --completion zsh)
|
|
3
|
+
|
|
4
|
+
_search_cli() {
|
|
5
|
+
local -a opts
|
|
6
|
+
opts=(
|
|
7
|
+
'--json[JSON output]'
|
|
8
|
+
'--csv[CSV output]'
|
|
9
|
+
'--markdown[Markdown table output]'
|
|
10
|
+
'--count[Only result count]'
|
|
11
|
+
'--limit[Max results]:limit:'
|
|
12
|
+
'--sort[Sort strategy]:(best-match stars updated forks)'
|
|
13
|
+
'--token[GitHub token]:token:'
|
|
14
|
+
'--trending[Trending mode]'
|
|
15
|
+
'--since[Trending period]:(daily weekly monthly)'
|
|
16
|
+
'--pipe[Pipe target]:(clone open)'
|
|
17
|
+
'--format[Output format]:(urls names ssh-urls clone-commands ids)'
|
|
18
|
+
'--watch[Watch mode]'
|
|
19
|
+
'--interval[Watch interval seconds]:seconds:'
|
|
20
|
+
'--version[Print version]'
|
|
21
|
+
'--help[Print help]'
|
|
22
|
+
'--completion[Shell]:(bash zsh fish)'
|
|
23
|
+
)
|
|
24
|
+
_arguments "${opts[@]}" '*:query:'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
compdef _search_cli search-cli
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "github-search-cli",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Terminal-native GitHub browser — search, trending, bookmarks, history, deep-dive, compare, export, and more. All keyboard-driven, no browser needed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"search-cli": "bin/search-cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"completions/",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "bun run src/cli.ts",
|
|
18
|
+
"build": "bun run scripts/build.ts",
|
|
19
|
+
"prepublishOnly": "bun run build",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"dev": "bun run --watch src/tui.ts"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@opentui/core": "^0.4.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/codersdfs/search-cli.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/codersdfs/search-cli/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/codersdfs/search-cli#readme",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"github",
|
|
42
|
+
"cli",
|
|
43
|
+
"search",
|
|
44
|
+
"trending",
|
|
45
|
+
"terminal",
|
|
46
|
+
"tui",
|
|
47
|
+
"browser",
|
|
48
|
+
"developer-tools",
|
|
49
|
+
"opentui"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/bookmarks.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bookmarks — saved repos with tags, persisted as JSON.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import type { Bookmark, Repo } from "./types.ts";
|
|
7
|
+
import { stateDir } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
const BOOKMARK_FILE = join(stateDir(), "bookmarks.json");
|
|
10
|
+
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadRaw(): Bookmark[] {
|
|
16
|
+
ensureDir();
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(BOOKMARK_FILE, "utf-8");
|
|
19
|
+
return JSON.parse(raw) as Bookmark[];
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saveRaw(bookmarks: Bookmark[]): void {
|
|
26
|
+
ensureDir();
|
|
27
|
+
writeFileSync(BOOKMARK_FILE, JSON.stringify(bookmarks, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Get all bookmarks, newest first. */
|
|
31
|
+
export function getBookmarks(): Bookmark[] {
|
|
32
|
+
return loadRaw().sort((a, b) => b.savedAt - a.savedAt);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Check if a repo is bookmarked by fullName. */
|
|
36
|
+
export function isBookmarked(fullName: string): boolean {
|
|
37
|
+
return loadRaw().some((b) => b.repo.fullName === fullName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Toggle bookmark: add if not present, remove if present. Returns new state. */
|
|
41
|
+
export function toggleBookmark(repo: Repo, tags: string[] = []): boolean {
|
|
42
|
+
const bookmarks = loadRaw();
|
|
43
|
+
const idx = bookmarks.findIndex((b) => b.repo.fullName === repo.fullName);
|
|
44
|
+
if (idx >= 0) {
|
|
45
|
+
bookmarks.splice(idx, 1);
|
|
46
|
+
saveRaw(bookmarks);
|
|
47
|
+
return false; // removed
|
|
48
|
+
}
|
|
49
|
+
bookmarks.push({ repo, savedAt: Date.now(), tags });
|
|
50
|
+
saveRaw(bookmarks);
|
|
51
|
+
return true; // added
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Remove a bookmark by fullName. */
|
|
55
|
+
export function removeBookmark(fullName: string): void {
|
|
56
|
+
const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
|
|
57
|
+
saveRaw(bookmarks);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Search bookmarks by text (matches name, description, tags). */
|
|
61
|
+
export function searchBookmarks(query: string): Bookmark[] {
|
|
62
|
+
const q = query.toLowerCase();
|
|
63
|
+
return getBookmarks().filter(
|
|
64
|
+
(b) =>
|
|
65
|
+
b.repo.fullName.toLowerCase().includes(q) ||
|
|
66
|
+
(b.repo.description ?? "").toLowerCase().includes(q) ||
|
|
67
|
+
b.tags.some((t) => t.toLowerCase().includes(q)),
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory LRU cache with TTL for API responses.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: in-memory only. Disk cache adds ~70 lines for marginal benefit
|
|
5
|
+
* (re-cache on restart rather than re-fetch). Add disk layer if cache-miss
|
|
6
|
+
* rate after restart becomes a problem.
|
|
7
|
+
*/
|
|
8
|
+
import type { CacheEntry } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_SIZE = 50;
|
|
11
|
+
|
|
12
|
+
export class MemoryCache<T> {
|
|
13
|
+
private cache = new Map<string, CacheEntry<T>>();
|
|
14
|
+
|
|
15
|
+
constructor(private defaultTtlMs: number = 300_000) {}
|
|
16
|
+
|
|
17
|
+
/** Build a normalized cache key. */
|
|
18
|
+
static key(...parts: string[]): string {
|
|
19
|
+
return parts.join("::").toLowerCase().replace(/\s+/g, " ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Get entry if not expired. Returns null on miss or expiry. */
|
|
23
|
+
get(key: string): T | null {
|
|
24
|
+
const entry = this.cache.get(key);
|
|
25
|
+
if (!entry) return null;
|
|
26
|
+
if (Date.now() - entry.cachedAt > entry.ttlMs) {
|
|
27
|
+
this.cache.delete(key);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Move to most-recently-used position
|
|
31
|
+
this.cache.delete(key);
|
|
32
|
+
this.cache.set(key, entry);
|
|
33
|
+
return entry.data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Set entry with optional per-entry TTL. */
|
|
37
|
+
set(key: string, data: T, ttlMs?: number): void {
|
|
38
|
+
if (this.cache.size >= MAX_SIZE) {
|
|
39
|
+
// Evict least-recently-used (first inserted key)
|
|
40
|
+
const firstKey = this.cache.keys().next().value;
|
|
41
|
+
if (firstKey !== undefined) this.cache.delete(firstKey);
|
|
42
|
+
}
|
|
43
|
+
this.cache.set(key, {
|
|
44
|
+
data,
|
|
45
|
+
cachedAt: Date.now(),
|
|
46
|
+
ttlMs: ttlMs ?? this.defaultTtlMs,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove entry. */
|
|
51
|
+
delete(key: string): void {
|
|
52
|
+
this.cache.delete(key);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Clear all entries. */
|
|
56
|
+
clear(): void {
|
|
57
|
+
this.cache.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Number of entries in cache. */
|
|
61
|
+
get size(): number {
|
|
62
|
+
return this.cache.size;
|
|
63
|
+
}
|
|
64
|
+
}
|