├── .gitignore ├── Makefile ├── api.go ├── config.go ├── daily.templ ├── db.go ├── go.mod ├── go.sum ├── mailer.go ├── main.go ├── sendgrid.go ├── service.go ├── store.go ├── types.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | bin/ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: build 2 | @./bin/notebase 3 | 4 | build: 5 | @go build -o bin/notebase 6 | 7 | test: 8 | @go test -v ./... -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | type APIServer struct { 12 | addr string 13 | db *sql.DB 14 | } 15 | 16 | func NewAPIServer(addr string, db *sql.DB) *APIServer { 17 | return &APIServer{addr: addr, db: db} 18 | } 19 | 20 | func (s *APIServer) Run() { 21 | router := mux.NewRouter() 22 | subrouter := router.PathPrefix("/api/v1").Subrouter() 23 | 24 | store := NewStore(s.db) 25 | mailer := NewSendGridMailer(Envs.SendGridAPIKey, Envs.SendGridFromEmail) 26 | 27 | service := NewService(store, mailer) 28 | service.RegisterRoutes(subrouter) 29 | 30 | log.Println("Starting API server on ", s.addr) 31 | 32 | log.Fatal(http.ListenAndServe(s.addr, subrouter)) 33 | } 34 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var Envs = initConfig() 8 | 9 | type Config struct { 10 | SendGridAPIKey string 11 | SendGridFromEmail string 12 | } 13 | 14 | func initConfig() Config { 15 | return Config{ 16 | SendGridAPIKey: getEnvOrPanic("SENDGRID_API_KEY", "SendGrid API KEY is required"), 17 | SendGridFromEmail: getEnvOrPanic("SENDGRID_FROM_EMAIL", "SendGrid From email is required"), 18 | } 19 | } 20 | 21 | func getEnvOrPanic(key, err string) string { 22 | if value, ok := os.LookupEnv(key); ok { 23 | return value 24 | } 25 | 26 | panic(err) 27 | } 28 | -------------------------------------------------------------------------------- /daily.templ: -------------------------------------------------------------------------------- 1 | 2 | 3 | Insights 4 | 5 | 6 |

Hey {{ .User.FirstName }}, here are your daily daily insight(s)

7 | 8 |
    9 | {{ range .Insights }} 10 |
  1. 11 |
    12 | 15 | "{{ .Text }}" 16 | 17 | {{ .Note }} 18 | 19 | 20 |
    - {{ .BookTitle }} - {{ .BookAuthors }}
    21 |
    22 |
  2. 23 | {{ end }} 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/go-sql-driver/mysql" 9 | ) 10 | 11 | type MySQLStorage struct { 12 | db *sql.DB 13 | } 14 | 15 | func NewMySQLStorage(cfg mysql.Config) *MySQLStorage { 16 | db, err := sql.Open("mysql", cfg.FormatDSN()) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | pingErr := db.Ping() 22 | if pingErr != nil { 23 | log.Fatal(pingErr) 24 | } 25 | 26 | fmt.Println("Connected!") 27 | 28 | return &MySQLStorage{db: db} 29 | } 30 | 31 | func (s *MySQLStorage) Init() (*sql.DB, error) { 32 | // initialize the tables 33 | if err := s.createUsersTable(); err != nil { 34 | return nil, err 35 | } 36 | 37 | if err := s.createBooksTable(); err != nil { 38 | return nil, err 39 | } 40 | 41 | if err := s.createHighlightsTable(); err != nil { 42 | return nil, err 43 | } 44 | 45 | return s.db, nil 46 | } 47 | 48 | func (s *MySQLStorage) createUsersTable() error { 49 | _, err := s.db.Exec(` 50 | CREATE TABLE IF NOT EXISTS users ( 51 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 52 | email VARCHAR(255) NOT NULL, 53 | firstName VARCHAR(255) NOT NULL, 54 | lastName VARCHAR(255) NOT NULL, 55 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 56 | 57 | PRIMARY KEY (id), 58 | UNIQUE KEY (email) 59 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 60 | `) 61 | 62 | return err 63 | 64 | } 65 | 66 | func (s *MySQLStorage) createBooksTable() error { 67 | _, err := s.db.Exec(` 68 | CREATE TABLE IF NOT EXISTS books ( 69 | isbn VARCHAR(255) NOT NULL, 70 | title VARCHAR(255) NOT NULL, 71 | authors VARCHAR(255) NOT NULL, 72 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 73 | 74 | PRIMARY KEY (isbn), 75 | UNIQUE KEY (isbn) 76 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 77 | `) 78 | 79 | return err 80 | } 81 | 82 | func (s *MySQLStorage) createHighlightsTable() error { 83 | _, err := s.db.Exec(` 84 | CREATE TABLE IF NOT EXISTS highlights ( 85 | id INT NOT NULL AUTO_INCREMENT, 86 | text TEXT, 87 | location VARCHAR(255) NOT NULL, 88 | note TEXT, 89 | userId INT UNSIGNED NOT NULL, 90 | bookId VARCHAR(255) NOT NULL, 91 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 92 | 93 | PRIMARY KEY (id), 94 | FOREIGN KEY (userId) REFERENCES users(id), 95 | FOREIGN KEY (bookId) REFERENCES books(isbn) 96 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 97 | `) 98 | 99 | return err 100 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sikozonpc/notebase 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.7.1 7 | github.com/gorilla/mux v1.8.1 8 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible 9 | ) 10 | 11 | require github.com/sendgrid/rest v2.6.9+incompatible // indirect 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 2 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 3 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 4 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 5 | github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= 6 | github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= 7 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA= 8 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= 9 | -------------------------------------------------------------------------------- /mailer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Mailer interface { 4 | SendInsights(ins []*DailyInsight, u *User) error 5 | } 6 | 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/go-sql-driver/mysql" 7 | ) 8 | 9 | func main() { 10 | cfg := mysql.Config{ 11 | User: "root", 12 | Passwd: "mypassword", 13 | Net: "tcp", 14 | Addr: "127.0.0.1:3306", 15 | DBName: "highlights", 16 | AllowNativePasswords: true, 17 | ParseTime: true, 18 | } 19 | 20 | storage := NewMySQLStorage(cfg) 21 | 22 | db, err := storage.Init() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | apiServer := NewAPIServer(":3000", db) 28 | apiServer.Run() 29 | } 30 | -------------------------------------------------------------------------------- /sendgrid.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | 8 | "github.com/sendgrid/sendgrid-go" 9 | "github.com/sendgrid/sendgrid-go/helpers/mail" 10 | ) 11 | 12 | var FromName = "Notebase" 13 | 14 | type SendGridMailer struct { 15 | FromEmail string 16 | Client *sendgrid.Client 17 | } 18 | 19 | func NewSendGridMailer(apiKey, fromEmail string) *SendGridMailer { 20 | client := sendgrid.NewSendClient(apiKey) 21 | 22 | return &SendGridMailer{ 23 | FromEmail: fromEmail, 24 | Client: client, 25 | } 26 | } 27 | 28 | func (m *SendGridMailer) SendInsights(insights []*DailyInsight, u *User) error { 29 | if u.Email == "" { 30 | return fmt.Errorf("user has no email") 31 | } 32 | 33 | from := mail.NewEmail(FromName, m.FromEmail) 34 | subject := "Daily Insight(s)" 35 | userName := fmt.Sprintf("%v %v", u.FirstName, u.LastName) 36 | 37 | to := mail.NewEmail(userName, u.Email) 38 | 39 | html := BuildInsightsMailTemplate(u, insights) 40 | 41 | message := mail.NewSingleEmail(from, subject, to, "", html) 42 | _, err := m.Client.Send(message) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func BuildInsightsMailTemplate(u *User, ins []*DailyInsight) string { 51 | templ, err := template.ParseFiles("daily.templ") 52 | if err != nil { 53 | panic(err) 54 | } 55 | 56 | payload := struct { 57 | User *User 58 | Insights []*DailyInsight 59 | }{ 60 | User: u, 61 | Insights: ins, 62 | } 63 | 64 | var out bytes.Buffer 65 | err = templ.Execute(&out, payload) 66 | if err != nil { 67 | panic(err) 68 | } 69 | 70 | return out.String() 71 | } 72 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "mime/multipart" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | type Service struct { 15 | store Storage 16 | mailer Mailer 17 | } 18 | 19 | func NewService(store Storage, mailer Mailer) *Service { 20 | return &Service{store: store, mailer: mailer} 21 | } 22 | 23 | func (s *Service) RegisterRoutes(router *mux.Router) { 24 | router.HandleFunc("/users/{userID}/parse-kindle-file", s.handleParseKindleFile).Methods("POST") 25 | router.HandleFunc("/cloud/send-daily-insights", s.handleSendDailyInsights).Methods("GET") 26 | } 27 | 28 | func (s *Service) handleParseKindleFile(w http.ResponseWriter, r *http.Request) { 29 | vars := mux.Vars(r) 30 | userID := vars["userID"] 31 | 32 | file, _, err := r.FormFile("file") 33 | if err != nil { 34 | WriteJSON(w, http.StatusBadRequest, fmt.Sprintf("Error parsing file: %v", err)) 35 | return 36 | } 37 | 38 | defer file.Close() 39 | 40 | // parse that multipart file 41 | raw, err := parseKindleExtractFile(file) 42 | if err != nil { 43 | WriteJSON(w, http.StatusBadRequest, fmt.Sprintf("Error parsing file: %v", err)) 44 | return 45 | } 46 | 47 | userIDint, _ := strconv.Atoi(userID) 48 | if err := s.createDataFromRawBook(raw, userIDint); err != nil { 49 | WriteJSON(w, http.StatusInternalServerError, fmt.Sprintf("Error creating data from raw book: %v", err)) 50 | return 51 | } 52 | 53 | WriteJSON(w, http.StatusOK, "Successfully parsed file") 54 | } 55 | 56 | func (s *Service) handleSendDailyInsights(w http.ResponseWriter, r *http.Request) { 57 | // get users 58 | users, err := s.store.GetUsers() 59 | if err != nil { 60 | WriteJSON(w, http.StatusInternalServerError, err.Error()) 61 | return 62 | } 63 | 64 | // loop over users and get random highlights 65 | for _, u := range users { 66 | hs, err := s.store.GetRandomHighlights(3, u.ID) 67 | if err != nil { 68 | WriteJSON(w, http.StatusInternalServerError, err.Error()) 69 | return 70 | } 71 | 72 | if len(hs) == 0 { 73 | continue 74 | } 75 | 76 | insights, err := s.buildInsights(hs) 77 | if err != nil { 78 | WriteJSON(w, http.StatusInternalServerError, err.Error()) 79 | return 80 | } 81 | 82 | err = s.mailer.SendInsights(insights, u) 83 | if err != nil { 84 | WriteJSON(w, http.StatusInternalServerError, err.Error()) 85 | return 86 | } 87 | } 88 | 89 | WriteJSON(w, http.StatusOK, nil) 90 | } 91 | 92 | func parseKindleExtractFile(file multipart.File) (*RawExtractBook, error) { 93 | decoder := json.NewDecoder(file) 94 | 95 | raw := new(RawExtractBook) 96 | if err := decoder.Decode(raw); err != nil { 97 | return nil, err 98 | } 99 | 100 | return raw, nil 101 | } 102 | 103 | func (s *Service) createDataFromRawBook(raw *RawExtractBook, userID int) error { 104 | _, err := s.store.GetBookByISBN(raw.ASIN) 105 | if err != nil { 106 | s.store.CreateBook(Book{ 107 | ISBN: raw.ASIN, 108 | Title: raw.Title, 109 | Authors: raw.Authors, 110 | }) 111 | } 112 | 113 | // create highlights 114 | hs := make([]Highlight, len(raw.Highlights)) 115 | for i, h := range raw.Highlights { 116 | hs[i] = Highlight{ 117 | Text: h.Text, 118 | Location: h.Location.URL, 119 | Note: h.Note, 120 | UserID: userID, 121 | BookID: raw.ASIN, 122 | } 123 | } 124 | 125 | err = s.store.CreateHighlights(hs) 126 | if err != nil { 127 | log.Println("Error creating highlights: ", err) 128 | return err 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (s *Service) buildInsights(hs []*Highlight) ([]*DailyInsight, error) { 135 | var insights []*DailyInsight 136 | 137 | for _, h := range hs { 138 | book, err := s.store.GetBookByISBN(h.BookID) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | insights = append(insights, &DailyInsight{ 144 | Text: h.Text, 145 | Note: h.Note, 146 | BookAuthors: book.Authors, 147 | BookTitle: book.Title, 148 | }) 149 | } 150 | 151 | return insights, nil 152 | } 153 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | type Store struct { 9 | db *sql.DB 10 | } 11 | 12 | type Storage interface { 13 | CreateBook(Book) error 14 | CreateHighlights([]Highlight) error 15 | GetBookByISBN(string) (*Book, error) 16 | GetRandomHighlights(limit, userId int) ([]*Highlight, error) 17 | GetUsers() ([]*User, error) 18 | } 19 | 20 | func NewStore(db *sql.DB) *Store { 21 | return &Store{db: db} 22 | } 23 | 24 | func (s *Store) CreateBook(b Book) error { 25 | _, err := s.db.Exec(` 26 | INSERT INTO books (isbn, title, authors) 27 | VALUES (?, ?, ?) 28 | `, b.ISBN, b.Title, b.Authors) 29 | 30 | if err != nil { 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func (s *Store) CreateHighlights(hs []Highlight) error { 38 | values := []interface{}{} 39 | 40 | query := "INSERT INTO highlights (text, location, note, userId, bookId) VALUES " 41 | for _, h := range hs { 42 | query += "(?, ?, ?, ?, ?)," 43 | values = append(values, h.Text, h.Location, h.Note, h.UserID, h.BookID) 44 | } 45 | 46 | query = query[:len(query)-1] 47 | 48 | _, err := s.db.Exec(query, values...) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (s *Store) GetBookByISBN(isbn string) (*Book, error) { 57 | rows, err := s.db.Query(` 58 | SELECT * FROM books WHERE isbn = ? 59 | `, isbn) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | book := new(Book) 65 | for rows.Next() { 66 | if err := rows.Scan(&book.ISBN, &book.Title, &book.Authors, &book.CreatedAt); err != nil { 67 | return nil, err 68 | } 69 | } 70 | 71 | if book.ISBN == "" { 72 | return nil, fmt.Errorf("book not found") 73 | } 74 | 75 | return book, nil 76 | } 77 | 78 | func (s *Store) GetUsers() ([]*User, error) { 79 | rows, err := s.db.Query("SELECT * FROM users") 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | users := make([]*User, 0) 85 | for rows.Next() { 86 | u := new(User) 87 | 88 | if err := rows.Scan( 89 | &u.ID, 90 | &u.Email, 91 | &u.FirstName, 92 | &u.LastName, 93 | &u.CreatedAt, 94 | ); err != nil { 95 | return nil, err 96 | } 97 | 98 | users = append(users, u) 99 | } 100 | 101 | return users, nil 102 | } 103 | 104 | func (s *Store) GetRandomHighlights(n, userID int) ([]*Highlight, error) { 105 | rows, err := s.db.Query("SELECT * FROM highlights WHERE userId = ? ORDER BY RAND() LIMIT ?", userID, n) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | var highlights []*Highlight 111 | for rows.Next() { 112 | h := new(Highlight) 113 | 114 | if err := rows.Scan( 115 | &h.ID, 116 | &h.Text, 117 | &h.Location, 118 | &h.Note, 119 | &h.UserID, 120 | &h.BookID, 121 | &h.CreatedAt, 122 | ); err != nil { 123 | return nil, err 124 | } 125 | 126 | highlights = append(highlights, h) 127 | } 128 | 129 | return highlights, nil 130 | } 131 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type RawExtractBook struct { 6 | ASIN string `json:"asin"` 7 | Title string `json:"title"` 8 | Authors string `json:"authors"` 9 | Highlights []RawExtractHighlight `json:"highlights"` 10 | } 11 | 12 | type RawExtractHighlight struct { 13 | Text string `json:"text"` 14 | Location struct { 15 | Value int `json:"value"` 16 | URL string `json:"url"` 17 | } `json:"location"` 18 | IsNoteOnly bool `json:"isNoteOnly"` 19 | Note string `json:"note"` 20 | } 21 | 22 | type Book struct { 23 | ISBN string `json:"isbn"` 24 | Title string `json:"title"` 25 | Authors string `json:"authors"` 26 | CreatedAt time.Time `json:"created_at"` 27 | } 28 | 29 | type Highlight struct { 30 | ID int `json:"id"` 31 | Text string `json:"text"` 32 | Location string `json:"location"` 33 | Note string `json:"note"` 34 | UserID int `json:"userId"` 35 | BookID string `json:"bookId"` 36 | CreatedAt time.Time `json:"created_at"` 37 | } 38 | 39 | type User struct { 40 | ID int `json:"id"` 41 | Email string `json:"email"` 42 | FirstName string `json:"firstName"` 43 | LastName string `json:"lastName"` 44 | CreatedAt time.Time `json:"created_at"` 45 | } 46 | 47 | type DailyInsight struct { 48 | Text string 49 | Note string 50 | BookAuthors string 51 | BookTitle string 52 | } 53 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | 9 | func WriteJSON(w http.ResponseWriter, status int, v any) { 10 | w.Header().Set("Content-Type", "application/json") 11 | w.WriteHeader(status) 12 | json.NewEncoder(w).Encode(v) 13 | } --------------------------------------------------------------------------------