librapi/services/book_store.go

270 lines
5.6 KiB
Go

package services
import (
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
)
var (
ErrFileCreation = errors.New("unexpected error occurred while creating book file")
ErrFileCopy = errors.New("unexpected error occurred while copying book file")
)
type IStore interface {
Save(bm *BookMetadata, content io.ReadCloser) error
Search(value string) ([]BookMetadata, error)
GetStoreDir() string
}
var _ IStore = (*BookStore)(nil)
const (
sqlSearchBooks = `
select
b.*
from
books b
where
lower(b.name) like lower(?)
or lower(b.description) like lower(?)
or lower(b.editor) like lower(?)
or lower(b.year) like lower(?)
union
select
b2.*
from books b2, json_each(b2.authors)
where lower(json_each.value) like lower(?)
union
select
b3.*
from books b3, json_each(b3.keywords)
where lower(json_each.value) like lower(?)
`
sqlInsertBook = "insert into books(name, description, editor, authors, year, keywords, path) values (?,?,?,?,?,?,?)"
sqlCreateBookTable = `
create table if not exists books (
name text primary key,
description text,
editor text not null,
authors jsonb not null,
year int not null,
keywords jsonb,
path text not null
)
`
)
type BookMetadata struct {
Name string
Description *string
Editor string
Authors []string
Year uint16
Keywords []string
path string
}
func (bm *BookMetadata) getAuthors() (string, error) {
authors, err := json.Marshal(bm.Authors)
if err != nil {
log.Err(err).Msg("unable to marshal authors")
return "", err
}
return string(authors), nil
}
func (bm *BookMetadata) getKeywords() (*string, error) {
if bm.Keywords == nil {
return nil, nil
}
keywords, err := json.Marshal(bm.Keywords)
if err != nil {
log.Err(err).Msg("unable to marshal keywords")
return nil, err
}
strKeywords := string(keywords)
return &strKeywords, nil
}
func (bm *BookMetadata) getFormattedName() string {
name := strings.ReplaceAll(strings.ToLower(bm.Name), " ", "-")
editor := strings.ReplaceAll(strings.ToLower(bm.Editor), " ", "-")
author := strings.ReplaceAll(strings.ToLower(bm.Authors[0]), " ", "-")
return fmt.Sprintf("%s_%s_%s_%d.pdf", name, editor, author, bm.Year)
}
func (bm *BookMetadata) intoStmtValues() ([]any, error) {
authors, err := bm.getAuthors()
if err != nil {
return nil, err
}
keywords, err := bm.getKeywords()
if err != nil {
log.Err(err).Msg("unable to marshal authors")
return nil, err
}
return []any{
bm.Name,
bm.Description,
bm.Editor,
authors,
bm.Year,
keywords,
bm.path,
}, nil
}
func (bm *BookMetadata) GetPath() string {
return bm.path
}
type BookStore struct {
processing *sync.WaitGroup
dir string
db *sql.DB
}
func NewBookStore(dir string) *BookStore {
if err := os.MkdirAll(dir, 0755); err != nil { //nolint
log.Fatal().Err(err).Msg("unable to create store dir")
}
bs := &BookStore{
processing: &sync.WaitGroup{},
dir: dir,
}
bs.init(dir)
return bs
}
func (bs *BookStore) init(dir string) {
db, err := sql.Open("sqlite3", filepath.Join(dir, "librapi.db"))
if err != nil {
log.Fatal().Err(err).Msg("unable initialize sqlite3 database")
}
if _, err := db.Exec(sqlCreateBookTable); err != nil {
log.Fatal().Err(err).Msg("unable to create books table")
}
bs.db = db
}
func (bs *BookStore) store(bm *BookMetadata) error {
stmt, err := bs.db.Prepare(sqlInsertBook)
if err != nil {
log.Err(err).Msg("unable to parse insert book sql")
return err
}
defer stmt.Close()
values, err := bm.intoStmtValues()
if err != nil {
return err
}
if _, err := stmt.Exec(values...); err != nil {
log.Err(err).Msg("unable to insert new book")
return err
}
return nil
}
func (bs *BookStore) Done() <-chan struct{} {
chDone := make(chan struct{})
go func() {
bs.processing.Wait()
if err := bs.db.Close(); err != nil {
log.Err(err).Msg("unable to close the database")
}
chDone <- struct{}{}
log.Info().Msg("book store processing done")
}()
return chDone
}
func (bs *BookStore) GetStoreDir() string {
return bs.dir
}
func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error {
bs.processing.Add(1)
defer bs.processing.Done()
defer content.Close()
bm.path = filepath.Join(bs.dir, bm.getFormattedName())
dst, err := os.Create(bm.path)
if err != nil {
log.Err(err).Msg(ErrFileCreation.Error())
return ErrFileCreation
}
defer dst.Close()
if _, err := io.Copy(dst, content); err != nil {
log.Err(err).Msg(ErrFileCopy.Error())
return ErrFileCopy
}
return bs.store(bm)
}
func (bs *BookStore) Search(value string) ([]BookMetadata, error) {
stmt, err := bs.db.Prepare(sqlSearchBooks)
if err != nil {
log.Err(err).Msg("unable to parse search book sql")
return nil, err
}
defer stmt.Close()
values := []any{}
for i := 0; i <= 5; i++ {
values = append(values, fmt.Sprintf("%%%s%%", value))
}
rows, err := stmt.Query(values...)
if err != nil {
log.Err(err).Msg("unable to query books")
return nil, err
}
bms := []BookMetadata{}
for rows.Next() {
var bm BookMetadata
var authors string
var keyword *string
if err := rows.Scan(&bm.Name, &bm.Description, &bm.Editor, &authors, &bm.Year, &keyword, &bm.path); err != nil {
log.Err(err).Msg("unable to scan row")
return nil, err
}
var authorsSlice []string
if err := json.Unmarshal([]byte(authors), &authorsSlice); err != nil {
log.Err(err).Msg("unable to unmarshal authors into slice")
return nil, err
}
bm.Authors = authorsSlice
bms = append(bms, bm)
}
return bms, nil
}