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) } var _ IStore = (*BookStore)(nil) //go:embed sql/create_books_table.sql var sqlCreateTable string //go:embed sql/insert_book.sql var sqlInsertBook string //go:embed sql/search_books.sql var sqlSearchBooks string 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 } 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(sqlCreateTable); 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) 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 { return nil, err } bm.Authors = authorsSlice bms = append(bms, bm) } return bms, nil }