librapi/services/store.go
2025-01-08 12:14:29 +01:00

278 lines
5.6 KiB
Go

package services
import (
"bytes"
"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 resource file")
ErrFileCopy = errors.New("unexpected error occurred while copying resource file")
)
type IStore interface {
Create(r *Resource) error
Search(value string) ([]Resource, error)
GetDir() string
}
var _ IStore = (*Store)(nil)
const (
sqlSearchResources = `
select
r.*
from
resources r
where
lower(r.name) like lower(?)
or lower(r.description) like lower(?)
or lower(r.editor) like lower(?)
or lower(r.year) like lower(?)
union
select
r2.*
from resources r2, json_each(r2.authors)
where lower(json_each.value) like lower(?)
union
select
r3.*
from resources r3, json_each(r3.keywords)
where lower(json_each.value) like lower(?)
`
sqlInsertResource = "insert into resources(name, description, editor, authors, year, keywords, path) values (?,?,?,?,?,?,?)"
sqlCreateResourceTable = `
create table if not exists resources (
name text primary key,
description text,
editor text not null,
authors jsonb not null,
year int not null,
keywords jsonb,
path text not null
)
`
)
func sanitizeFilename(name string) string {
return strings.ReplaceAll(
strings.ReplaceAll(
strings.ToLower(name), " ", "-",
),
"'", "",
)
}
type Resource struct {
Name string
Description *string
Editor string
Authors []string
Year int
Keywords []string
Content *bytes.Buffer
Path string
}
func (r *Resource) getAuthors() (string, error) {
authors, err := json.Marshal(r.Authors)
if err != nil {
log.Err(err).Msg("unable to marshal authors")
return "", err
}
return string(authors), nil
}
func (r *Resource) getKeywords() (*string, error) {
if r.Keywords == nil {
return nil, nil
}
keywords, err := json.Marshal(r.Keywords)
if err != nil {
log.Err(err).Msg("unable to marshal keywords")
return nil, err
}
strKeywords := string(keywords)
return &strKeywords, nil
}
func (r *Resource) getFormattedName() string {
name := sanitizeFilename(r.Name)
editor := sanitizeFilename(r.Editor)
author := sanitizeFilename((r.Authors[0]))
return fmt.Sprintf("%s_%s_%s_%d.pdf", name, editor, author, r.Year)
}
func (r *Resource) intoStmtValues() ([]any, error) {
authors, err := r.getAuthors()
if err != nil {
return nil, err
}
keywords, err := r.getKeywords()
if err != nil {
log.Err(err).Msg("unable to marshal authors")
return nil, err
}
return []any{
r.Name,
r.Description,
r.Editor,
authors,
r.Year,
keywords,
r.Path,
}, nil
}
type Store struct {
processing *sync.WaitGroup
dir string
db *sql.DB
}
func NewStore(dir string) *Store {
if err := os.MkdirAll(dir, 0755); err != nil { //nolint
log.Fatal().Err(err).Msg("unable to create store dir")
}
s := &Store{
processing: &sync.WaitGroup{},
dir: dir,
}
s.init(dir)
log.Info().Str("dir", s.dir).Msg("store initialized")
return s
}
func (s *Store) 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(sqlCreateResourceTable); err != nil {
log.Fatal().Err(err).Msg("unable to create resources table")
}
s.db = db
}
func (s *Store) createRessource(r *Resource) error {
stmt, err := s.db.Prepare(sqlInsertResource)
if err != nil {
log.Err(err).Msg("unable to parse insert resource sql")
return err
}
defer stmt.Close()
values, err := r.intoStmtValues()
if err != nil {
return err
}
if _, err := stmt.Exec(values...); err != nil {
log.Err(err).Msg("unable to insert new resource")
return err
}
return nil
}
func (s *Store) Done() <-chan struct{} {
chDone := make(chan struct{})
go func() {
s.processing.Wait()
if err := s.db.Close(); err != nil {
log.Err(err).Msg("unable to close the database")
}
chDone <- struct{}{}
log.Info().Msg("resource store processing done")
}()
return chDone
}
func (s *Store) GetDir() string {
return s.dir
}
func (s *Store) Create(r *Resource) error {
s.processing.Add(1)
defer s.processing.Done()
path := filepath.Join(s.dir, r.getFormattedName())
r.Path = path
dst, err := os.Create(path)
if err != nil {
log.Err(err).Msg(ErrFileCreation.Error())
return ErrFileCreation
}
defer dst.Close()
if _, err := io.Copy(dst, r.Content); err != nil {
log.Err(err).Msg(ErrFileCopy.Error())
return ErrFileCopy
}
return s.createRessource(r)
}
func (s *Store) Search(value string) ([]Resource, error) {
stmt, err := s.db.Prepare(sqlSearchResources)
if err != nil {
log.Err(err).Msg("unable to parse search resource 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 resources")
return nil, err
}
resources := []Resource{}
for rows.Next() {
var resource Resource
var authors string
var keyword *string
if err := rows.Scan(&resource.Name, &resource.Description, &resource.Editor, &authors, &resource.Year, &keyword, &resource.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
}
resource.Authors = authorsSlice
resources = append(resources, resource)
}
return resources, nil
}