Discover books, shows, and movies at your level. Track your progress by filling your Shelf with what you find, and share with other language learners. *No dusting required. shlf.space
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sqlite3 db for data persistence

Signed-off-by: brookjeynes <me@brookjeynes.dev>

authored by

brookjeynes and committed by tangled.org d33685a1 940e879c

+241 -51
+1
go.mod
··· 19 19 github.com/gorilla/securecookie v1.1.2 // indirect 20 20 github.com/gorilla/sessions v1.4.0 // indirect 21 21 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 22 + github.com/mattn/go-sqlite3 v1.14.40 // indirect 22 23 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 23 24 github.com/prometheus/client_golang v1.17.0 // indirect 24 25 github.com/prometheus/client_model v0.5.0 // indirect
+2
go.sum
··· 112 112 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 113 113 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 114 114 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 115 + github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= 116 + github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 115 117 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 116 118 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 117 119 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
+1
internal/config/config.go
··· 10 10 11 11 type CoreConfig struct { 12 12 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 13 + DbPath string `env:"DB_PATH, default=shlf.db"` 13 14 Dev bool `env:"DEV, default=false"` 14 15 ListenAddr string `env:"PORT, default=0.0.0.0:8080"` 15 16 Host string `env:"HOST, default=https://shlf.space"`
+48
internal/db/book.go
··· 1 + package db 2 + 3 + import ( 4 + "log" 5 + 6 + "shlf.space/internal/types" 7 + ) 8 + 9 + func GetBooksByDID(e Execer, did string) ([]types.Book, error) { 10 + query := ` 11 + select 12 + pc.book_id, 13 + pc.platform, 14 + b.title, 15 + b.author, 16 + b.description 17 + from profile_catalogue as pc 18 + join books as b 19 + on pc.platform = b.platform 20 + and pc.book_id = b.book_id 21 + where pc.did = ?; 22 + ` 23 + 24 + rows, err := e.Query(query, did) 25 + if err != nil { 26 + return nil, err 27 + } 28 + defer rows.Close() 29 + 30 + var catalogue []types.Book 31 + 32 + for rows.Next() { 33 + var book types.Book 34 + 35 + err := rows.Scan(&book.ID, &book.Platform, &book.Title, &book.Author, &book.Description) 36 + if err != nil { 37 + log.Printf("failed to scan book row: %v", err) 38 + continue 39 + } 40 + 41 + catalogue = append(catalogue, book) 42 + } 43 + if err := rows.Err(); err != nil { 44 + return nil, err 45 + } 46 + 47 + return catalogue, nil 48 + }
+85
internal/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + ) 11 + 12 + type DB struct { 13 + *sql.DB 14 + } 15 + 16 + type Execer interface { 17 + Query(query string, args ...any) (*sql.Rows, error) 18 + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) 19 + QueryRow(query string, args ...any) *sql.Row 20 + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row 21 + Exec(query string, args ...any) (sql.Result, error) 22 + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) 23 + Prepare(query string) (*sql.Stmt, error) 24 + PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 25 + } 26 + 27 + func Make(ctx context.Context, dbPath string) (*DB, error) { 28 + opts := []string{ 29 + "_foreign_keys=1", 30 + "_journal_mode=WAL", 31 + "_synchronous=NORMAL", 32 + "_auto_vacuum=incremental", 33 + } 34 + 35 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 36 + if err != nil { 37 + return nil, fmt.Errorf("failed to open db: %w", err) 38 + } 39 + 40 + conn, err := db.Conn(ctx) 41 + if err != nil { 42 + return nil, err 43 + } 44 + defer conn.Close() 45 + 46 + _, err = conn.ExecContext(ctx, ` 47 + create table if not exists profiles ( 48 + did text primary key, 49 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 50 + ); 51 + 52 + create table if not exists books ( 53 + book_id text not null, 54 + platform text not null, 55 + title text not null, 56 + author text not null, 57 + description text not null, 58 + primary key (platform, book_id) 59 + ); 60 + 61 + create table if not exists profile_catalogue ( 62 + did text not null, 63 + platform text not null, 64 + book_id text not null, 65 + primary key (did, platform, book_id), 66 + foreign key (did) references profiles(did) on delete cascade, 67 + foreign key (platform, book_id) references books(platform, book_id) on delete cascade 68 + ); 69 + 70 + create table if not exists shelf_items ( 71 + did text not null, 72 + type text not null, 73 + position integer not null, 74 + platform text, 75 + book_id text, 76 + primary key (did, position), 77 + foreign key (platform, book_id) references books(platform, book_id) on delete cascade 78 + ); 79 + `) 80 + if err != nil { 81 + return nil, fmt.Errorf("failed to execute db create statement: %w", err) 82 + } 83 + 84 + return &DB{db}, nil 85 + }
+64
internal/db/shelf.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "log" 6 + 7 + "shlf.space/internal/types" 8 + ) 9 + 10 + func GetShelf(e Execer, did string) ([]types.ShelfItem, error) { 11 + query := ` 12 + select 13 + si.type, 14 + si.book_id, 15 + si.platform, 16 + b.title, 17 + b.author, 18 + b.description 19 + from shelf_items as si 20 + left join books as b 21 + on si.platform = b.platform 22 + and si.book_id = b.book_id 23 + where si.did = ? 24 + order by si.position; 25 + ` 26 + 27 + rows, err := e.Query(query, did) 28 + if err != nil { 29 + return nil, err 30 + } 31 + defer rows.Close() 32 + 33 + var shelf []types.ShelfItem 34 + 35 + for rows.Next() { 36 + var shelfItem types.ShelfItem 37 + var bookID, platform, title, author, description sql.NullString 38 + 39 + err := rows.Scan(&shelfItem.Type, &bookID, &platform, &title, &author, &description) 40 + if err != nil { 41 + log.Printf("failed to scan shelf row: %v", err) 42 + continue 43 + } 44 + 45 + if shelfItem.Type == types.ShelfItemBook && bookID.Valid { 46 + book := types.Book{ 47 + ID: bookID.String, 48 + Platform: platform.String, 49 + Title: title.String, 50 + Author: author.String, 51 + Description: description.String, 52 + } 53 + bookView := book.View() 54 + shelfItem.Book = &bookView 55 + } 56 + 57 + shelf = append(shelf, shelfItem) 58 + } 59 + if err := rows.Err(); err != nil { 60 + return nil, err 61 + } 62 + 63 + return shelf, nil 64 + }
+16 -7
internal/server/server.go
··· 4 4 "context" 5 5 "fmt" 6 6 7 + "github.com/bluesky-social/indigo/atproto/identity" 7 8 "shlf.space/internal/atproto" 8 9 "shlf.space/internal/cache" 9 10 "shlf.space/internal/cache/session" 10 11 "shlf.space/internal/config" 12 + "shlf.space/internal/db" 11 13 "shlf.space/internal/server/oauth" 12 14 ) 13 15 ··· 16 18 config *config.Config 17 19 idResolver *atproto.Resolver 18 20 session *session.SessionStore 21 + database *db.DB 19 22 } 20 23 21 24 func Make(ctx context.Context, config *config.Config) (*Server, error) { 22 25 idResolver := atproto.DefaultResolver() 23 26 27 + database, err := db.Make(ctx, config.Core.DbPath) 28 + if err != nil { 29 + return nil, err 30 + } 31 + 24 32 oauth, err := oauth.New(config, idResolver) 25 33 if err != nil { 26 34 return nil, fmt.Errorf("failed to start oauth handler: %w", err) ··· 30 38 session := session.New(cache) 31 39 32 40 return &Server{ 41 + database: database, 33 42 oauth: oauth, 34 43 config: config, 35 44 idResolver: idResolver, ··· 38 47 } 39 48 40 49 func (s *Server) Close() error { 41 - return nil 50 + return s.database.Close() 42 51 } 43 52 44 - func (s *Server) resolveDidToHandle(did string) string { 45 - identity, err := s.idResolver.ResolveIdent(context.Background(), did) 53 + func (s *Server) resolveIdentity(didOrHandle string) (*identity.Identity, error) { 54 + ident, err := s.idResolver.ResolveIdent(context.Background(), didOrHandle) 46 55 if err != nil { 47 - return did 56 + return nil, err 48 57 } 49 58 50 - if identity.Handle.IsInvalidHandle() { 51 - return "handle.invalid" 59 + if ident.Handle.IsInvalidHandle() { 60 + return nil, fmt.Errorf("handle is invalid") 52 61 } 53 62 54 - return identity.Handle.String() 63 + return ident, nil 55 64 }
+21 -41
internal/server/shelf.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 + "shlf.space/internal/db" 8 + "shlf.space/internal/server/htmx" 7 9 "shlf.space/internal/types" 8 10 "shlf.space/internal/views/shelf" 9 11 ) ··· 16 18 http.Error(w, "Bad request", http.StatusBadRequest) 17 19 return 18 20 } 19 - handle := s.resolveDidToHandle(didOrHandle) 20 21 21 - // TODO: test data 22 - catalog := []types.Book{ 23 - { 24 - PlatformID: types.PlatformYes24, 25 - ID: "124807552", 26 - Title: "메멘과 모리", 27 - Author: "요시타케 신스케", 28 - Description: "'사람은 무엇을 위해 살아가는가?', '살아가는 의미와 목적이 필요한가?'에 대한 정답 없는 고민으로 괴로운 당신에게 요시타케 신스케가 전하는 세 가지 이야기.", 29 - }, 30 - { 31 - PlatformID: types.PlatformYes24, 32 - ID: "58552371", 33 - Title: "세계를 건너 너에게 갈게", 34 - Author: "이꽃님", 35 - Description: "'나에게. 아빠가 쓰라고 해서 쓰는 거야.' 첫 문장으로 시작한 편지가 '세계를 건너 너에게 갈게.'라는 마지막 문장에 닿기까지, 두 사람의 진심이 하나의 진실을 향해 가는 동안 쌓아올린 감동은 많은 독자들에게 울음을 울게 만들었다.", 36 - }, 22 + profileIdentity, err := s.resolveIdentity(didOrHandle) 23 + if err != nil { 24 + http.Error(w, "Profile not found", http.StatusNotFound) 25 + return 37 26 } 38 - catalogView := make([]types.BookView, 2) 39 - for _, book := range catalog { 40 - catalogView = append(catalogView, book.View()) 27 + 28 + catalog, err := db.GetBooksByDID(s.database, profileIdentity.DID.String()) 29 + if err != nil { 30 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get books, try again later.") 31 + return 32 + } 33 + catalogView := make([]types.BookView, len(catalog)) 34 + for i, b := range catalog { 35 + catalogView[i] = b.View() 41 36 } 42 37 43 - // TODO: test data 44 - items := []types.ShelfItem{ 45 - { 46 - Type: types.ShelfItemBook, 47 - Book: &catalogView[0], 48 - }, 49 - { 50 - Type: types.ShelfItemSpacer, 51 - }, 52 - { 53 - Type: types.ShelfItemSpacer, 54 - }, 55 - { 56 - Type: types.ShelfItemBook, 57 - Book: &catalogView[1], 58 - }, 59 - { 60 - Type: types.ShelfItemSpacer, 61 - }, 38 + shelfItems, err := db.GetShelf(s.database, profileIdentity.DID.String()) 39 + if err != nil { 40 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get shelf, try again later.") 41 + return 62 42 } 63 43 64 44 shelf.ShelfPage(shelf.ShelfPageParams{ 65 45 User: user, 66 - ProfileHandle: handle, 46 + ProfileHandle: profileIdentity.Handle.String(), 67 47 Catalog: catalogView, 68 - Items: items, 48 + Items: shelfItems, 69 49 }).Render(r.Context(), w) 70 50 }
+3 -3
internal/types/book.go
··· 10 10 11 11 type Book struct { 12 12 ID string `json:"id"` 13 - PlatformID string `json:"platformId"` 13 + Platform string `json:"platform"` 14 14 Title string `json:"title"` 15 15 Author string `json:"author"` 16 16 Description string `json:"description"` 17 17 } 18 18 19 19 func (book Book) SpineImageURL() string { 20 - switch book.PlatformID { 20 + switch book.Platform { 21 21 case PlatformYes24: 22 22 return fmt.Sprintf("https://image.yes24.com/goods/%s/SIDE/XL", book.ID) 23 23 } ··· 25 25 } 26 26 27 27 func (book Book) CoverImageURL() string { 28 - switch book.PlatformID { 28 + switch book.Platform { 29 29 case PlatformYes24: 30 30 return fmt.Sprintf("https://image.yes24.com/goods/%s/XL", book.ID) 31 31 }