├── .env.example ├── .github └── workflows │ ├── docker-build.yml │ └── golangci-lint.yml ├── .gitignore ├── Dockerfile ├── LICENCE ├── README.md ├── cmd └── app │ └── main.go ├── docker-compose.yml ├── go.mod ├── go.sum └── internal ├── github ├── example.repo.json └── service.go └── karakeep ├── models.go └── service.go /.env.example: -------------------------------------------------------------------------------- 1 | GH_TOKEN= 2 | KK_HOST= 3 | KK_TOKEN= 4 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: docker-build 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | image_tag: 7 | description: 'tag' 8 | required: true 9 | default: 'latest' 10 | 11 | permissions: 12 | packages: write 13 | contents: read 14 | attestations: write 15 | id-token: write 16 | 17 | jobs: 18 | docker-build: 19 | name: docker-build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: docker/setup-buildx-action@v3 24 | - uses: docker/login-action@v3 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | - uses: docker/build-push-action@v6 30 | with: 31 | context: ${{ github.workspace }}/ 32 | file: ${{ github.workspace }}/Dockerfile 33 | platforms: linux/amd64, linux/arm64 34 | push: true 35 | tags: ghcr.io/${{ github.repository_owner }}/github2karakeep:${{ github.event.inputs.image_tag }} 36 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | golangci-lint: 15 | name: golangci-lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version-file: 'go.mod' 22 | - uses: golangci/golangci-lint-action@v7 23 | with: 24 | version: v2.1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | WORKDIR /tmp/build 4 | COPY go.mod ./ 5 | RUN go mod download 6 | 7 | COPY . . 8 | 9 | RUN go build -o app cmd/app/main.go 10 | 11 | FROM alpine:latest 12 | COPY --from=builder /tmp/build/app /usr/bin/app 13 | 14 | CMD ["app"] -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hasan Sino 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github2karakeep 2 | 3 | Export starred repositories from GitHub to a KaraKeep list. 4 | 5 | ## Running 6 | 7 | docker 8 | 9 | ```bash 10 | docker run --rm \ 11 | -e TIMEOUT=10s \ 12 | -e GH_USERNAME={username} \ 13 | -e GH_TOKEN={token} \ 14 | -e GH_PER_PAGE=100 \ 15 | -e GH_EXTRACT_TOPICS=true \ 16 | -e KK_HOST={host} \ 17 | -e KK_TOKEN={token} \ 18 | -e KK_LIST=github2karakeep \ 19 | -e UPDATE_INTERVAL=24h \ 20 | -e EXPORT_LIMIT=10 \ 21 | -e DEFAULT_TAG=github2karakeep \ 22 | ghcr.io/hasansino/github2karakeep:latest 23 | ``` 24 | 25 | docker-compose 26 | 27 | ```yaml 28 | services: 29 | github2karakeep: 30 | image: ghcr.io/hasansino/github2karakeep:latest 31 | environment: 32 | - TIMEOUT=10s 33 | - GH_USERNAME={username} 34 | - GH_TOKEN={token} 35 | - GH_PER_PAGE=100 36 | - GH_EXTRACT_TOPICS=true 37 | - KK_HOST={host} 38 | - KK_TOKEN={token} 39 | - KK_LIST=github2karakeep 40 | - UPDATE_INTERVAL=24h 41 | - EXPORT_LIMIT=10 42 | - DEFAULT_TAG=github2karakeep 43 | ``` 44 | 45 | ## Configuration 46 | 47 | | CLI Argument | Environment Variable | Description | Default Value | 48 | |-----------------------|-----------------------|----------------------------------------------------------------|-------------------| 49 | | `--timeout` | `TIMEOUT` | Timeout for HTTP requests. Duration format: `2h45m30s`. | `10s` | 50 | | `--gh-user` | `GH_USERNAME` | GitHub username. **Required**. | | 51 | | `--gh-token` | `GH_TOKEN` | GitHub token with starring/read-only permission. **Required**. | | 52 | | `--gh-per-page` | `GH_PER_PAGE` | Number of repositories to fetch per page. | `100` | 53 | | `--gh-extract-topics` | `GH_EXTRACT_TOPICS` | Extract topics from repository description as tags. | `false` | 54 | | `--kk-host` | `KK_HOST` | KaraKeep host, including schema. **Required**. | | 55 | | `--kk-token` | `KK_TOKEN` | KaraKeep API token. **Required**. | | 56 | | `--kk-list` | `KK_LIST` | KaraKeep list name. | `github2karakeep` | 57 | | `--update-interval` | `UPDATE_INTERVAL` | Update interval. Duration format: `2h45m30s`. | `24h` | 58 | | `--export-limit` | `EXPORT_LIMIT` | Limit the number of repositories to export per run. | `10` | 59 | | `--default-tag` | `DEFAULT_TAG` | Default tag to add to every bookmark. Leave empty to omit. | `github2karakeep` | 60 | 61 | ## Notes 62 | 63 | + Ensure your GitHub token has the necessary permissions (starring/read-only). 64 | + KaraKeep host should include the schema (e.g., https://example.com). 65 | + Use the UPDATE_INTERVAL to control how often the export process runs. 66 | + GitHub API rate limits may apply. 67 | + Increase GH_PER_PAGE to lower number of requests if you have a large number of starred repositories. 68 | + GitHub API allows up to 100 repositories per page. 69 | + EXPORT_LIMIT meant to be used for tests (exporting a small number of repositories). 70 | + EXPORT_LIMIT does not persist state between runs. 71 | + Set EXPORT_LIMIT to 0 to disable limit. 72 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "sync" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/alecthomas/kingpin/v2" 14 | 15 | "github.com/alecthomas/hasansino/github2karakeep/internal/github" 16 | "github.com/alecthomas/hasansino/github2karakeep/internal/karakeep" 17 | ) 18 | 19 | const ( 20 | DefaultReposPerPage = "100" 21 | RequestTimeout = "10s" 22 | DefaultKarakeepListName = "github2karakeep" 23 | DefaultUpdateInterval = "24h" 24 | DefaultExportLimit = "10" 25 | DefaultTagName = "github2karakeep" 26 | DefaultExtractTopics = "false" 27 | ) 28 | 29 | var wg sync.WaitGroup 30 | 31 | func main() { 32 | timeout := kingpin.Flag("timeout", "timeout for the requests"). 33 | Envar("TIMEOUT").Default(RequestTimeout).Duration() 34 | ghUser := kingpin.Flag("gh-user", "github username"). 35 | Envar("GH_USERNAME").Required().String() 36 | ghToken := kingpin.Flag("gh-token", "github personal access token"). 37 | Envar("GH_TOKEN").Required().String() 38 | ghPerPage := kingpin.Flag("gh-per-page", "number of repos per page"). 39 | Envar("GH_PER_PAGE").Default(DefaultReposPerPage).Int() 40 | ghExtractTopics := kingpin.Flag("gh-extract-topics", "extract topics from repo description as tags"). 41 | Envar("GH_EXTRACT_TOPICS").Default(DefaultExtractTopics).Bool() 42 | kkHost := kingpin.Flag("kk-host", "karakeep host"). 43 | Envar("KK_HOST").Required().String() 44 | kkToken := kingpin.Flag("kk-token", "karakeep token"). 45 | Envar("KK_TOKEN").Required().String() 46 | kkList := kingpin.Flag("kk-list", "karakeep list"). 47 | Envar("KK_LIST").Default(DefaultKarakeepListName).String() 48 | updateInterval := kingpin.Flag("update-interval", "update interval"). 49 | Envar("UPDATE_INTERVAL").Default(DefaultUpdateInterval).Duration() 50 | exportLimit := kingpin.Flag("export-limit", "export limit"). 51 | Envar("EXPORT_LIMIT").Default(DefaultExportLimit).Int() 52 | defaultTag := kingpin.Flag("default-tag", "default tag for bookmark"). 53 | Envar("DEFAULT_TAG").Default(DefaultTagName).String() 54 | 55 | kingpin.Parse() 56 | 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | 59 | ghService := github.New(*timeout, *ghToken, *ghPerPage) 60 | kkService := karakeep.New(*timeout, *kkHost, *kkToken, *defaultTag) 61 | 62 | wg.Add(1) 63 | go Run(ctx, *updateInterval, *exportLimit, ghService, *ghUser, kkService, *kkList, *ghExtractTopics) 64 | 65 | sys := make(chan os.Signal, 1) 66 | signal.Notify(sys, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 67 | shutdown(<-sys, cancel) 68 | } 69 | 70 | func Run( 71 | ctx context.Context, 72 | updateInterval time.Duration, 73 | exportLimit int, 74 | ghService *github.Service, 75 | ghUser string, 76 | kkService *karakeep.Service, 77 | kkList string, 78 | ghExtractTopics bool, 79 | ) { 80 | ticker := time.NewTicker(updateInterval) 81 | defer wg.Done() 82 | defer ticker.Stop() 83 | 84 | // always run at start 85 | err := run(ctx, exportLimit, ghService, ghUser, kkService, kkList, ghExtractTopics) 86 | if err != nil { 87 | log.Printf("Failed to execute exporter: %s\n", err) 88 | } 89 | 90 | for { 91 | select { 92 | case <-ctx.Done(): 93 | return 94 | case <-ticker.C: 95 | err := run(ctx, exportLimit, ghService, ghUser, kkService, kkList, ghExtractTopics) 96 | if err != nil { 97 | log.Printf("Failed to execute exporter: %s\n", err) 98 | } 99 | } 100 | } 101 | } 102 | 103 | func run( 104 | ctx context.Context, 105 | exportLimit int, 106 | ghService *github.Service, 107 | ghUser string, 108 | kkService *karakeep.Service, 109 | kkList string, 110 | ghExtractTopics bool, 111 | ) error { 112 | 113 | log.Printf("Starting exporter...") 114 | 115 | // --- Retrieve starred repos --- 116 | 117 | log.Printf("Retrieving starred repos...") 118 | 119 | allRepos, err := ghService.GetStarredRepos(ctx, ghUser) 120 | if err != nil { 121 | return fmt.Errorf("failed to retrieve starred repos: %w", err) 122 | } 123 | 124 | log.Printf("Total starred repos: %d\n", len(allRepos)) 125 | 126 | // --- Retrieve karakeep lists --- 127 | lists, err := kkService.GetAllLists(ctx) 128 | if err != nil { 129 | return fmt.Errorf("failed to retrieve karakeep lists: %w", err) 130 | } 131 | 132 | // --- Check if default list exists --- 133 | listID := "" 134 | for _, list := range lists { 135 | if list.Name == kkList { 136 | listID = list.ID 137 | log.Printf("Found karakeep list %s with id %s\n", kkList, listID) 138 | break 139 | } 140 | } 141 | 142 | if len(listID) == 0 { 143 | log.Printf("Karakeep list %s not found. Creating...\n", kkList) 144 | list, err := kkService.CreateList(ctx, kkList) 145 | if err != nil { 146 | return fmt.Errorf("failed to create list: %w", err) 147 | } 148 | listID = list.ID 149 | } 150 | 151 | // --- Create / Update bookmarks --- 152 | 153 | log.Printf("Creating bookmarks...") 154 | 155 | var counter int 156 | for i := range allRepos { 157 | repo := allRepos[i] 158 | if repo.Repository.FullName == nil { 159 | log.Printf("Repo id %v is invalid - missing full_name field.\n", repo.Repository.ID) 160 | continue 161 | } 162 | if repo.Repository.HTMLURL == nil { 163 | log.Printf("Repo id %v is invalid - missing html_url feild.\n", repo.Repository.ID) 164 | continue 165 | } 166 | var repoDesc string 167 | if repo.Repository.Description != nil { 168 | repoDesc = *repo.Repository.Description 169 | } 170 | bookmark, err := kkService.CreateBookmark( 171 | ctx, 172 | *repo.Repository.FullName, 173 | *repo.Repository.HTMLURL, 174 | repoDesc, 175 | ) 176 | if err != nil { 177 | return fmt.Errorf("failed to create bookmark: %w", err) 178 | } 179 | err = kkService.AddBookmarkToList(ctx, bookmark.ID, listID) 180 | if err != nil { 181 | return fmt.Errorf("failed to attach bookmark to list: %w", err) 182 | } 183 | 184 | var ghTopics []string 185 | if ghExtractTopics { 186 | ghTopics = repo.Repository.Topics 187 | } 188 | 189 | err = kkService.AddTagsToBookmark(ctx, bookmark.ID, ghTopics) 190 | if err != nil { 191 | return fmt.Errorf("failed to attach tags to bookmark: %w", err) 192 | } 193 | 194 | counter++ 195 | 196 | if counter%100 == 0 { 197 | log.Printf("Processed %d bookmarks.\n", counter) 198 | } 199 | 200 | if exportLimit > 0 && counter == exportLimit { 201 | break 202 | } 203 | } 204 | 205 | log.Printf("Exporter finished...") 206 | 207 | return nil 208 | } 209 | 210 | func shutdown( 211 | _ os.Signal, 212 | cancel context.CancelFunc, 213 | ) { 214 | cancel() 215 | wg.Wait() 216 | os.Exit(0) 217 | } 218 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | github2karakeep: 3 | image: ghcr.io/hasansino/github2karakeep:latest 4 | environment: 5 | - TIMEOUT=10s 6 | - GH_USERNAME=hasansino 7 | - GH_TOKEN=${GH_TOKEN} 8 | - GH_PER_PAGE=10 9 | - KK_HOST=${KK_HOST} 10 | - KK_TOKEN=${KK_TOKEN} 11 | - KK_LIST=github2karakeep 12 | - UPDATE_INTERVAL=1s 13 | - EXPORT_LIMIT=10 14 | - DEFAULT_TAG=github2karakeep -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alecthomas/hasansino/github2karakeep 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/alecthomas/kingpin/v2 v2.4.0 7 | github.com/google/go-github/v72 v72.0.0 8 | ) 9 | 10 | require ( 11 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 12 | github.com/google/go-querystring v1.1.0 // indirect 13 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 2 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 9 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 10 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 11 | github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= 12 | github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= 13 | github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= 14 | github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= 15 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 16 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 21 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 22 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 23 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 24 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 25 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 27 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 28 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 29 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 30 | -------------------------------------------------------------------------------- /internal/github/example.repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "starred_at": "2025-04-17T11:56:05Z", 3 | "repo": { 4 | "id": 138547797, 5 | "node_id": "MDEwOlJlcG9zaXRvcnkxMzg1NDc3OTc=", 6 | "owner": { 7 | "login": "FiloSottile", 8 | "id": 1225294, 9 | "node_id": "MDQ6VXNlcjEyMjUyOTQ=", 10 | "avatar_url": "https://avatars.githubusercontent.com/u/1225294?v=4", 11 | "html_url": "https://github.com/FiloSottile", 12 | "gravatar_id": "", 13 | "type": "User", 14 | "site_admin": false, 15 | "url": "https://api.github.com/users/FiloSottile", 16 | "events_url": "https://api.github.com/users/FiloSottile/events{/privacy}", 17 | "following_url": "https://api.github.com/users/FiloSottile/following{/other_user}", 18 | "followers_url": "https://api.github.com/users/FiloSottile/followers", 19 | "gists_url": "https://api.github.com/users/FiloSottile/gists{/gist_id}", 20 | "organizations_url": "https://api.github.com/users/FiloSottile/orgs", 21 | "received_events_url": "https://api.github.com/users/FiloSottile/received_events", 22 | "repos_url": "https://api.github.com/users/FiloSottile/repos", 23 | "starred_url": "https://api.github.com/users/FiloSottile/starred{/owner}{/repo}", 24 | "subscriptions_url": "https://api.github.com/users/FiloSottile/subscriptions" 25 | }, 26 | "name": "mkcert", 27 | "full_name": "FiloSottile/mkcert", 28 | "description": "A simple zero-config tool to make locally trusted development certificates with any names you'd like.", 29 | "homepage": "https://mkcert.dev", 30 | "default_branch": "master", 31 | "created_at": "2018-06-25T05:33:03Z", 32 | "pushed_at": "2024-08-13T13:37:46Z", 33 | "updated_at": "2025-04-18T06:04:22Z", 34 | "html_url": "https://github.com/FiloSottile/mkcert", 35 | "clone_url": "https://github.com/FiloSottile/mkcert.git", 36 | "git_url": "git://github.com/FiloSottile/mkcert.git", 37 | "ssh_url": "git@github.com:FiloSottile/mkcert.git", 38 | "svn_url": "https://github.com/FiloSottile/mkcert", 39 | "language": "Go", 40 | "fork": false, 41 | "forks_count": 2785, 42 | "open_issues_count": 151, 43 | "open_issues": 151, 44 | "stargazers_count": 53148, 45 | "watchers_count": 53148, 46 | "watchers": 53148, 47 | "size": 1799, 48 | "permissions": { 49 | "admin": false, 50 | "maintain": false, 51 | "pull": true, 52 | "push": false, 53 | "triage": false 54 | }, 55 | "allow_forking": true, 56 | "web_commit_signoff_required": false, 57 | "topics": [ 58 | "certificates", 59 | "chrome", 60 | "firefox", 61 | "https", 62 | "ios", 63 | "linux", 64 | "local-development", 65 | "localhost", 66 | "macos", 67 | "root-ca", 68 | "tls", 69 | "windows" 70 | ], 71 | "archived": false, 72 | "disabled": false, 73 | "license": { 74 | "key": "bsd-3-clause", 75 | "name": "BSD 3-Clause \"New\" or \"Revised\" License", 76 | "url": "https://api.github.com/licenses/bsd-3-clause", 77 | "spdx_id": "BSD-3-Clause" 78 | }, 79 | "private": false, 80 | "has_issues": true, 81 | "has_wiki": false, 82 | "has_pages": false, 83 | "has_projects": false, 84 | "has_downloads": true, 85 | "has_discussions": true, 86 | "is_template": false, 87 | "url": "https://api.github.com/repos/FiloSottile/mkcert", 88 | "archive_url": "https://api.github.com/repos/FiloSottile/mkcert/{archive_format}{/ref}", 89 | "assignees_url": "https://api.github.com/repos/FiloSottile/mkcert/assignees{/user}", 90 | "blobs_url": "https://api.github.com/repos/FiloSottile/mkcert/git/blobs{/sha}", 91 | "branches_url": "https://api.github.com/repos/FiloSottile/mkcert/branches{/branch}", 92 | "collaborators_url": "https://api.github.com/repos/FiloSottile/mkcert/collaborators{/collaborator}", 93 | "comments_url": "https://api.github.com/repos/FiloSottile/mkcert/comments{/number}", 94 | "commits_url": "https://api.github.com/repos/FiloSottile/mkcert/commits{/sha}", 95 | "compare_url": "https://api.github.com/repos/FiloSottile/mkcert/compare/{base}...{head}", 96 | "contents_url": "https://api.github.com/repos/FiloSottile/mkcert/contents/{+path}", 97 | "contributors_url": "https://api.github.com/repos/FiloSottile/mkcert/contributors", 98 | "deployments_url": "https://api.github.com/repos/FiloSottile/mkcert/deployments", 99 | "downloads_url": "https://api.github.com/repos/FiloSottile/mkcert/downloads", 100 | "events_url": "https://api.github.com/repos/FiloSottile/mkcert/events", 101 | "forks_url": "https://api.github.com/repos/FiloSottile/mkcert/forks", 102 | "git_commits_url": "https://api.github.com/repos/FiloSottile/mkcert/git/commits{/sha}", 103 | "git_refs_url": "https://api.github.com/repos/FiloSottile/mkcert/git/refs{/sha}", 104 | "git_tags_url": "https://api.github.com/repos/FiloSottile/mkcert/git/tags{/sha}", 105 | "hooks_url": "https://api.github.com/repos/FiloSottile/mkcert/hooks", 106 | "issue_comment_url": "https://api.github.com/repos/FiloSottile/mkcert/issues/comments{/number}", 107 | "issue_events_url": "https://api.github.com/repos/FiloSottile/mkcert/issues/events{/number}", 108 | "issues_url": "https://api.github.com/repos/FiloSottile/mkcert/issues{/number}", 109 | "keys_url": "https://api.github.com/repos/FiloSottile/mkcert/keys{/key_id}", 110 | "labels_url": "https://api.github.com/repos/FiloSottile/mkcert/labels{/name}", 111 | "languages_url": "https://api.github.com/repos/FiloSottile/mkcert/languages", 112 | "merges_url": "https://api.github.com/repos/FiloSottile/mkcert/merges", 113 | "milestones_url": "https://api.github.com/repos/FiloSottile/mkcert/milestones{/number}", 114 | "notifications_url": "https://api.github.com/repos/FiloSottile/mkcert/notifications{?since,all,participating}", 115 | "pulls_url": "https://api.github.com/repos/FiloSottile/mkcert/pulls{/number}", 116 | "releases_url": "https://api.github.com/repos/FiloSottile/mkcert/releases{/id}", 117 | "stargazers_url": "https://api.github.com/repos/FiloSottile/mkcert/stargazers", 118 | "statuses_url": "https://api.github.com/repos/FiloSottile/mkcert/statuses/{sha}", 119 | "subscribers_url": "https://api.github.com/repos/FiloSottile/mkcert/subscribers", 120 | "subscription_url": "https://api.github.com/repos/FiloSottile/mkcert/subscription", 121 | "tags_url": "https://api.github.com/repos/FiloSottile/mkcert/tags", 122 | "trees_url": "https://api.github.com/repos/FiloSottile/mkcert/git/trees{/sha}", 123 | "teams_url": "https://api.github.com/repos/FiloSottile/mkcert/teams", 124 | "visibility": "public" 125 | } 126 | } -------------------------------------------------------------------------------- /internal/github/service.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/google/go-github/v72/github" 11 | ) 12 | 13 | type Service struct { 14 | ghClient *github.Client 15 | perPage int 16 | timeout time.Duration 17 | } 18 | 19 | func New(timeout time.Duration, ghToken string, perPage int) *Service { 20 | return &Service{ 21 | ghClient: github.NewClient(&http.Client{Timeout: timeout}).WithAuthToken(ghToken), 22 | perPage: perPage, 23 | timeout: timeout, 24 | } 25 | } 26 | 27 | func (s *Service) GetStarredRepos(ctx context.Context, user string) ([]*github.StarredRepository, error) { 28 | opt := &github.ActivityListStarredOptions{ 29 | ListOptions: github.ListOptions{ 30 | PerPage: s.perPage, 31 | }, 32 | } 33 | 34 | allRepos := make([]*github.StarredRepository, 0) 35 | 36 | var page int 37 | for { 38 | reqCtx, cancel := context.WithTimeout(ctx, s.timeout) 39 | repos, resp, err := s.ghClient.Activity.ListStarred(reqCtx, user, opt) 40 | cancel() 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to retrieve starred: %w", err) 43 | } 44 | 45 | log.Printf("Retrieved page %d with %d repos\n", page, len(repos)) 46 | 47 | allRepos = append(allRepos, repos...) 48 | if resp.NextPage == 0 { 49 | break 50 | } 51 | opt.Page = resp.NextPage 52 | 53 | page++ 54 | } 55 | 56 | return allRepos, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/karakeep/models.go: -------------------------------------------------------------------------------- 1 | package karakeep 2 | 3 | import "strings" 4 | 5 | type ErrorResponse struct { 6 | Code string `json:"code"` 7 | Error string `json:"error"` 8 | } 9 | 10 | func (e *ErrorResponse) Contains(s string) bool { 11 | return strings.Contains(e.Error, s) 12 | } 13 | 14 | type List struct { 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | } 18 | 19 | type ListsResponse struct { 20 | Lists []List `json:"lists"` 21 | } 22 | 23 | type CreateListRequest struct { 24 | Name string `json:"name"` 25 | Icon string `json:"icon"` 26 | } 27 | 28 | type Bookmark struct { 29 | ID string `json:"id"` 30 | } 31 | 32 | const BookmarkTypeLink = "link" 33 | 34 | type CreateBookmarkRequest struct { 35 | Type string `json:"type"` 36 | URL string `json:"url"` 37 | Title string `json:"title"` 38 | Summary string `json:"summary"` 39 | } 40 | 41 | type AddBookmarkToListRequest struct { 42 | ListID string `json:"listId"` 43 | BookmarkID string `json:"bookmarkId"` 44 | } 45 | 46 | type AddTagsToBookmarkRequest struct { 47 | Tags []AddTagsToBookmarkRequestItem `json:"tags"` 48 | } 49 | 50 | type AddTagsToBookmarkRequestItem struct { 51 | TagName string `json:"tagName"` 52 | } 53 | -------------------------------------------------------------------------------- /internal/karakeep/service.go: -------------------------------------------------------------------------------- 1 | package karakeep 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const DefaultListIcon = "🔸" 15 | 16 | type Service struct { 17 | httpClient *http.Client 18 | host string 19 | token string 20 | defTag string 21 | } 22 | 23 | func New(timeout time.Duration, host string, token string, defTag string) *Service { 24 | return &Service{ 25 | httpClient: &http.Client{ 26 | Timeout: timeout, 27 | }, 28 | host: strings.Trim(host, "/"), 29 | token: token, 30 | defTag: defTag, 31 | } 32 | } 33 | 34 | func (s *Service) GetAllLists(ctx context.Context) ([]List, error) { 35 | reqUrl := fmt.Sprintf("%s/api/v1/lists", s.host) 36 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, nil) 37 | if err != nil { 38 | return nil, err 39 | } 40 | req.Header.Add("Accept", "application/json") 41 | req.Header.Add("Authorization", "Bearer "+s.token) 42 | 43 | res, err := s.httpClient.Do(req) 44 | if err != nil { 45 | return nil, err 46 | } 47 | defer func(b io.ReadCloser) { 48 | _ = b.Close() 49 | }(res.Body) 50 | 51 | if res.StatusCode != http.StatusOK { 52 | return nil, fmt.Errorf("http error: %s", res.Status) 53 | } 54 | 55 | response := new(ListsResponse) 56 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 57 | return nil, err 58 | } 59 | 60 | return response.Lists, nil 61 | } 62 | 63 | func (s *Service) CreateList(ctx context.Context, name string) (*List, error) { 64 | payload := CreateListRequest{ 65 | Name: name, 66 | Icon: DefaultListIcon, 67 | } 68 | 69 | jsonBytes, err := json.Marshal(payload) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | reqUrl := fmt.Sprintf("%s/api/v1/lists", s.host) 75 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewReader(jsonBytes)) 76 | if err != nil { 77 | return nil, err 78 | } 79 | req.Header.Add("Content-Type", "application/json") 80 | req.Header.Add("Accept", "application/json") 81 | req.Header.Add("Authorization", "Bearer "+s.token) 82 | 83 | res, err := s.httpClient.Do(req) 84 | if err != nil { 85 | return nil, err 86 | } 87 | defer func(b io.ReadCloser) { 88 | _ = b.Close() 89 | }(res.Body) 90 | 91 | if res.StatusCode != http.StatusCreated { 92 | return nil, fmt.Errorf("http error: %s", res.Status) 93 | } 94 | 95 | response := new(List) 96 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 97 | return nil, err 98 | } 99 | 100 | return response, nil 101 | } 102 | 103 | func (s *Service) CreateBookmark(ctx context.Context, title string, url string, desc string) (*Bookmark, error) { 104 | payload := CreateBookmarkRequest{ 105 | Type: BookmarkTypeLink, 106 | Title: title, 107 | URL: url, 108 | Summary: desc, 109 | } 110 | jsonBytes, err := json.Marshal(payload) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | reqUrl := fmt.Sprintf("%s/api/v1/bookmarks", s.host) 116 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewReader(jsonBytes)) 117 | if err != nil { 118 | return nil, err 119 | } 120 | req.Header.Add("Content-Type", "application/json") 121 | req.Header.Add("Accept", "application/json") 122 | req.Header.Add("Authorization", "Bearer "+s.token) 123 | 124 | res, err := s.httpClient.Do(req) 125 | if err != nil { 126 | return nil, err 127 | } 128 | defer func(b io.ReadCloser) { 129 | _ = b.Close() 130 | }(res.Body) 131 | 132 | if res.StatusCode != http.StatusCreated { 133 | return nil, fmt.Errorf("http error: %s", res.Status) 134 | } 135 | 136 | response := new(Bookmark) 137 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 138 | return nil, err 139 | } 140 | 141 | return response, nil 142 | } 143 | 144 | func (s *Service) AddBookmarkToList(ctx context.Context, bookmarkID string, listID string) error { 145 | reqUrl := fmt.Sprintf("%s/api/v1/lists/%s/bookmarks/%s", s.host, listID, bookmarkID) 146 | req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqUrl, nil) 147 | if err != nil { 148 | return err 149 | } 150 | req.Header.Add("Content-Type", "application/json") 151 | req.Header.Add("Accept", "application/json") 152 | req.Header.Add("Authorization", "Bearer "+s.token) 153 | 154 | res, err := s.httpClient.Do(req) 155 | if err != nil { 156 | return err 157 | } 158 | defer func(b io.ReadCloser) { 159 | _ = b.Close() 160 | }(res.Body) 161 | 162 | // should be idempotent instead 163 | // @see https://github.com/karakeep-app/karakeep/issues/1402 164 | if res.StatusCode != http.StatusNoContent { 165 | errorResp := new(ErrorResponse) 166 | if err := json.NewDecoder(res.Body).Decode(errorResp); err != nil { 167 | return err 168 | } 169 | if !errorResp.Contains("already in the list") { 170 | return fmt.Errorf("http error: %s", res.Status) 171 | } 172 | } 173 | 174 | return nil 175 | } 176 | 177 | func (s *Service) AddTagsToBookmark(ctx context.Context, bookmarkID string, tags []string) error { 178 | payload := AddTagsToBookmarkRequest{ 179 | Tags: make([]AddTagsToBookmarkRequestItem, 0), 180 | } 181 | if len(s.defTag) > 0 { 182 | payload.Tags = append(payload.Tags, AddTagsToBookmarkRequestItem{s.defTag}) 183 | } 184 | for i := range tags { 185 | payload.Tags = append(payload.Tags, AddTagsToBookmarkRequestItem{tags[i]}) 186 | } 187 | if len(payload.Tags) == 0 { 188 | return nil 189 | } 190 | jsonBytes, err := json.Marshal(payload) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | reqUrl := fmt.Sprintf("%s/api/v1/bookmarks/%s/tags", s.host, bookmarkID) 196 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewReader(jsonBytes)) 197 | if err != nil { 198 | return err 199 | } 200 | req.Header.Add("Content-Type", "application/json") 201 | req.Header.Add("Accept", "application/json") 202 | req.Header.Add("Authorization", "Bearer "+s.token) 203 | 204 | res, err := s.httpClient.Do(req) 205 | if err != nil { 206 | return err 207 | } 208 | defer func(b io.ReadCloser) { 209 | _ = b.Close() 210 | }(res.Body) 211 | 212 | if res.StatusCode != http.StatusOK { 213 | return fmt.Errorf("http error: %s", res.Status) 214 | } 215 | 216 | return nil 217 | } 218 | --------------------------------------------------------------------------------