Skip to content

Commit ffae7ad

Browse files
Initial commit
0 parents  commit ffae7ad

File tree

14 files changed

+808
-0
lines changed

14 files changed

+808
-0
lines changed

.gitignore

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Created by https://www.toptal.com/developers/gitignore/api/go,macos
2+
# Edit at https://www.toptal.com/developers/gitignore?templates=go,macos
3+
4+
### Go ###
5+
# If you prefer the allow list template instead of the deny list, see community template:
6+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
7+
#
8+
# Binaries for programs and plugins
9+
*.exe
10+
*.exe~
11+
*.dll
12+
*.so
13+
*.dylib
14+
15+
# Test binary, built with `go test -c`
16+
*.test
17+
18+
# Output of the go coverage tool, specifically when used with LiteIDE
19+
*.out
20+
21+
# Dependency directories (remove the comment below to include it)
22+
# vendor/
23+
24+
# Go workspace file
25+
go.work
26+
27+
### macOS ###
28+
# General
29+
.DS_Store
30+
.AppleDouble
31+
.LSOverride
32+
33+
# Icon must end with two \r
34+
Icon
35+
36+
37+
# Thumbnails
38+
._*
39+
40+
# Files that might appear in the root of a volume
41+
.DocumentRevisions-V100
42+
.fseventsd
43+
.Spotlight-V100
44+
.TemporaryItems
45+
.Trashes
46+
.VolumeIcon.icns
47+
.com.apple.timemachine.donotpresent
48+
49+
# Directories potentially created on remote AFP share
50+
.AppleDB
51+
.AppleDesktop
52+
Network Trash Folder
53+
Temporary Items
54+
.apdisk
55+
56+
### macOS Patch ###
57+
# iCloud generated files
58+
*.icloud
59+
60+
# End of https://www.toptal.com/developers/gitignore/api/go,macos
61+
62+
bin/*
63+
.env

Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM golang:1.21.1-alpine as build
2+
3+
WORKDIR /usr/src/app
4+
COPY go.mod go.sum ./
5+
RUN go mod download && go mod verify
6+
7+
COPY . .
8+
RUN go build -v -o /usr/local/bin/updater ./cmd/updater/main.go
9+
RUN go build -v -o /usr/local/bin/watcher ./cmd/watcher/main.go
10+
11+
# Release stage
12+
FROM alpine:3.18
13+
14+
WORKDIR /bin
15+
COPY --from=build /usr/local/bin/updater /bin/updater
16+
COPY --from=build /usr/local/bin/watcher /bin/watcher
17+
COPY .env .

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Notion IGDB autocomplete
2+
3+
![demo](https://github.com/RedSkiesReaperr/notion-igdb-autocomplete/assets/64477486/02de6e81-974f-4ed1-948a-e261cbd29eba)
4+
5+
## How does it works
6+
Watch the database you set up. Waits for a page with a title that matchs the following pattern: `{{GAME_TITLE}}`. Then it asks for IGDB informations and it fills the Notion page.
7+
8+
## Requirements
9+
- [Docker](https://www.docker.com/products/docker-desktop/)
10+
11+
## Setup
12+
1. Follow the [getting started](https://developers.notion.com/docs/create-a-notion-integration#create-your-integration-in-notion) to get your `NOTION_API_SECRET` & `NOTION_PAGE_ID`.
13+
2. Follow the [getting started](https://api-docs.igdb.com/#getting-started) IGDB API to get your `IGDB_CLIENT_ID` & `IGDB_SECRET`
14+
3. Put these in your `.env` file
15+
4. Complete `.env` following the `.env.example` file
16+
5. Run `docker-compose up` command

cmd/updater/main.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"net/http"
8+
"notion-igdb-autocomplete/config"
9+
"notion-igdb-autocomplete/igdb"
10+
"sort"
11+
"time"
12+
13+
"github.com/agnivade/levenshtein"
14+
"github.com/gin-gonic/gin"
15+
"github.com/jomei/notionapi"
16+
)
17+
18+
type body struct {
19+
PageID string `json:"page_id,required"`
20+
Search string `json:"search,required"`
21+
}
22+
23+
func main() {
24+
config, err := config.Load()
25+
if err != nil {
26+
log.Fatalf("Unable to load config: %s\n", err)
27+
}
28+
log.Println("Successfully loaded config!")
29+
30+
igdbClient, err := igdb.NewClient(config.IGDBClientID, config.IGDBSecret)
31+
if err != nil {
32+
log.Fatalf("Unable to create IGDB client: %s\n", err)
33+
}
34+
log.Println("Successfully created IGDB client!")
35+
36+
notionClient := notionapi.NewClient(notionapi.Token(config.NotionAPISecret))
37+
log.Println("Successfully created Notion client!")
38+
39+
server := gin.Default()
40+
server.PUT("/", func(ctx *gin.Context) {
41+
var payload body
42+
43+
err := ctx.ShouldBindJSON(&payload)
44+
if err != nil {
45+
ctx.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
46+
return
47+
}
48+
49+
game, err := searchIgdbGame(payload.Search, &igdbClient)
50+
if err != nil {
51+
ctx.JSON(http.StatusNotFound, gin.H{"message": err.Error()})
52+
return
53+
}
54+
55+
updatedPage, err := updateNotionPage(payload.PageID, game, notionClient)
56+
if err != nil {
57+
ctx.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
58+
return
59+
}
60+
61+
ctx.JSON(http.StatusOK, gin.H{"data": fmt.Sprintf("Updated page %s with game %s infos", updatedPage.ID, game.Name)})
62+
})
63+
64+
err = server.Run(fmt.Sprintf("0.0.0.0:%d", config.UpdaterPort))
65+
if err != nil {
66+
log.Fatalf("Unable to start server: %s\n", err)
67+
}
68+
}
69+
70+
func searchIgdbGame(gameName string, client *igdb.Client) (*igdb.Game, error) {
71+
query := igdb.NewSearchQuery(gameName, []string{"name", "platforms.name", "first_release_date", "franchises.name", "genres.name", "cover.image_id"})
72+
results, err := client.SearchGame(query)
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
if len(results) <= 0 {
78+
return nil, fmt.Errorf("cannot find game '%s'", gameName)
79+
}
80+
81+
return findBestGame(gameName, results), nil
82+
}
83+
84+
func updateNotionPage(pageID string, game *igdb.Game, notionClient *notionapi.Client) (*notionapi.Page, error) {
85+
releaseDate := notionapi.Date(time.Unix(game.ReleaseDate, 0))
86+
platforms := game.NotionPlatforms()
87+
genres := game.NotionGenres()
88+
franchises := game.NotionFranchises()
89+
90+
updateReq := notionapi.PageUpdateRequest{
91+
Cover: &notionapi.Image{
92+
Type: "external",
93+
External: &notionapi.FileObject{
94+
URL: game.CoverURL(),
95+
},
96+
},
97+
Properties: notionapi.Properties{
98+
"Title": notionapi.TitleProperty{
99+
Type: notionapi.PropertyTypeTitle,
100+
Title: []notionapi.RichText{
101+
{Text: &notionapi.Text{Content: game.Name}},
102+
},
103+
},
104+
"Release date": notionapi.DateProperty{
105+
Type: notionapi.PropertyTypeDate,
106+
107+
Date: &notionapi.DateObject{
108+
Start: &releaseDate,
109+
},
110+
},
111+
},
112+
}
113+
114+
if len(platforms) > 0 {
115+
updateReq.Properties["Platforms"] = notionapi.MultiSelectProperty{
116+
Type: notionapi.PropertyTypeMultiSelect,
117+
MultiSelect: game.NotionPlatforms(),
118+
}
119+
}
120+
121+
if len(franchises) > 0 {
122+
updateReq.Properties["Franchises"] = notionapi.MultiSelectProperty{
123+
Type: notionapi.PropertyTypeMultiSelect,
124+
MultiSelect: franchises,
125+
}
126+
}
127+
128+
if len(genres) > 0 {
129+
updateReq.Properties["Genres"] = notionapi.MultiSelectProperty{
130+
Type: notionapi.PropertyTypeMultiSelect,
131+
MultiSelect: game.NotionGenres(),
132+
}
133+
}
134+
135+
page, err := notionClient.Page.Update(context.Background(), notionapi.PageID(pageID), &updateReq)
136+
if err != nil {
137+
return nil, err
138+
}
139+
140+
return page, nil
141+
}
142+
143+
type ComparedGames []ComparedGame
144+
type ComparedGame struct {
145+
Game igdb.Game
146+
Index int
147+
}
148+
149+
// Implements interface sort.Interface
150+
func (cg ComparedGames) Len() int {
151+
return len(cg)
152+
}
153+
154+
// Implements interface sort.Interface
155+
func (cg ComparedGames) Swap(i, j int) {
156+
cg[i], cg[j] = cg[j], cg[i]
157+
}
158+
159+
// Implements interface sort.Interface
160+
func (cg ComparedGames) Less(i, j int) bool {
161+
return cg[i].Index < cg[j].Index
162+
}
163+
164+
func findBestGame(search string, games igdb.Games) *igdb.Game {
165+
var comparisons ComparedGames
166+
167+
for _, game := range games {
168+
comparisons = append(comparisons, ComparedGame{
169+
Game: game,
170+
Index: levenshtein.ComputeDistance(search, game.Name),
171+
})
172+
}
173+
174+
sort.Sort(comparisons)
175+
176+
return &comparisons[0].Game
177+
}

cmd/watcher/main.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log"
9+
"net/http"
10+
"notion-igdb-autocomplete/config"
11+
"strings"
12+
"time"
13+
14+
"github.com/jomei/notionapi"
15+
)
16+
17+
func main() {
18+
config, err := config.Load()
19+
if err != nil {
20+
log.Fatalf("Unable to load config: %s\n", err)
21+
} else {
22+
log.Println("Successfully loaded config!")
23+
}
24+
25+
titleCleaner := strings.NewReplacer("{{", "", "}}", "")
26+
27+
log.Println("Looking for pages to update...")
28+
for range time.Tick(time.Duration(config.WatcherTickDelay)) {
29+
pages, err := fetchPages(config.NotionAPISecret, config.NotionPageID)
30+
if err != nil {
31+
log.Fatalf("Unable to fetch pages: %s\n", err)
32+
}
33+
34+
for _, obj := range pages {
35+
id := obj.ID
36+
title := obj.Properties["Title"].(*notionapi.TitleProperty).Title[0].Text.Content
37+
cleanTitle := titleCleaner.Replace(title)
38+
39+
err = callUpdater(config.UpdaterURL(), updaterBody{PageID: id.String(), Search: cleanTitle})
40+
if err != nil {
41+
log.Printf("Unable to update page '%s': %s\n", id, err)
42+
} else {
43+
log.Printf("page '%s' successfully updated!", id)
44+
}
45+
}
46+
}
47+
}
48+
49+
func fetchPages(apiSecret string, databaseID string) ([]notionapi.Page, error) {
50+
notion := notionapi.NewClient(notionapi.Token(apiSecret))
51+
query := &notionapi.DatabaseQueryRequest{
52+
Filter: &notionapi.AndCompoundFilter{
53+
notionapi.PropertyFilter{
54+
Property: "Title",
55+
RichText: &notionapi.TextFilterCondition{
56+
StartsWith: "{{",
57+
},
58+
},
59+
notionapi.PropertyFilter{
60+
Property: "Title",
61+
RichText: &notionapi.TextFilterCondition{
62+
EndsWith: "}}",
63+
},
64+
},
65+
},
66+
}
67+
68+
result, err := notion.Database.Query(context.Background(), notionapi.DatabaseID(databaseID), query)
69+
if err != nil {
70+
return nil, err
71+
}
72+
73+
return result.Results, nil
74+
}
75+
76+
type updaterBody struct {
77+
PageID string `json:"page_id,required"`
78+
Search string `json:"search,required"`
79+
}
80+
81+
func callUpdater(updaterURL string, payload updaterBody) error {
82+
reqBody, err := json.Marshal(payload)
83+
if err != nil {
84+
return err
85+
}
86+
87+
httpClient := &http.Client{}
88+
req, err := http.NewRequest("PUT", updaterURL, strings.NewReader(string(reqBody)))
89+
if err != nil {
90+
return err
91+
}
92+
93+
log.Printf("Requesting update: %s\n", reqBody)
94+
resp, err := httpClient.Do(req)
95+
if err != nil {
96+
return err
97+
}
98+
defer resp.Body.Close()
99+
100+
body, err := io.ReadAll(resp.Body)
101+
if err != nil {
102+
return err
103+
}
104+
105+
if resp.StatusCode != 200 {
106+
return fmt.Errorf("%s: %s", resp.Status, body)
107+
}
108+
109+
return nil
110+
}

0 commit comments

Comments
 (0)