diff --git a/go.mod b/go.mod index 6525cfd..1624959 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/uuid v1.3.1 github.com/julienschmidt/httprouter v1.3.0 github.com/sirupsen/logrus v1.8.1 + gopkg.in/yaml.v2 v2.4.0 gorm.io/gorm v1.25.5 ) diff --git a/go.sum b/go.sum index 0cc21d6..acd1cf6 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= diff --git a/hasher/sha256/sha256.go b/hasher/sha256/sha256.go index 706acae..169c639 100644 --- a/hasher/sha256/sha256.go +++ b/hasher/sha256/sha256.go @@ -52,4 +52,3 @@ func (h *Default256Hasher) Calculate(hashes []string) string { return h.hashFn(strings.Join(orderedHashes, "")) } -- diff --git a/models/answer.go b/models/answer.go index 46c8613..8dfb80a 100644 --- a/models/answer.go +++ b/models/answer.go @@ -1,6 +1,27 @@ package models +import ( + "crypto/sha256" + "fmt" +) + type Answer struct { ID string `json:"id" gorm:"primaryKey"` Text string `json:"text"` } + +func (a *Answer) String() string { + return a.Text +} + +func (a *Answer) GetID() string { + return a.ID +} + +func (a *Answer) SetID(id string) { + a.ID = id +} + +func (a *Answer) GetHash() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(a.Text))) +} diff --git a/models/collection.go b/models/collection.go index 647a983..da09d0a 100644 --- a/models/collection.go +++ b/models/collection.go @@ -1,10 +1,30 @@ package models +type Filter struct { + Tags []*Tag +} + type Collection struct { Meta - Name string `json:"name"` - Query string `json:"query"` + Name string `json:"name"` + Filter *Filter `json:"filter"` Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"` } + +func (c *Collection) String() string { + return c.Name +} + +func (c *Collection) GetID() string { + return c.ID +} + +func (c *Collection) SetID(id string) { + c.ID = id +} + +func (c *Collection) GetHash() string { + return "" +} diff --git a/models/meta.go b/models/meta.go index abdd51b..d7e9a8d 100644 --- a/models/meta.go +++ b/models/meta.go @@ -6,5 +6,4 @@ type Meta struct { ID string `json:"id" yaml:"id" gorm:"primaryKey"` CreatedAt time.Time `json:"created_at" yaml:"created_at"` UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` - Tags []*Tag `json:"tags" yaml:"-" gorm:"-"` } diff --git a/models/models_test.go b/models/models_test.go index ad52032..2ae81a2 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -1,6 +1,8 @@ package models import ( + "fmt" + "reflect" "testing" "github.com/remogatto/prettytest" @@ -16,3 +18,59 @@ func TestRunner(t *testing.T) { new(testSuite), ) } + +func (t *testSuite) TestQuizFromMarkdown() { + markdown := `Question text (1). + +Question text (2). + +Question text with #tag1 #tag2 (3). + +* Answer 1 +* Answer 2 +* Answer 3 +* Answer 4` + + expectedQuiz := &Quiz{ + Question: &Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."}, + Answers: []*Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + } + + quiz, _, err := MarkdownToQuiz(markdown) + t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err)) + + if !t.Failed() { + t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz)) + + } +} + +func (t *testSuite) TestMarkdownFromQuiz() { + quiz := &Quiz{ + Question: &Question{Text: "Newly created question text."}, + Answers: []*Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + } + md, err := QuizToMarkdown(quiz) + t.Nil(err, fmt.Sprintf("Conversion to markdown should not raise an error: %v", err)) + if !t.Failed() { + t.Equal(`Newly created question text. + +* Answer 1 +* Answer 2 +* Answer 3 +* Answer 4 +`, md) + } +} diff --git a/models/question.go b/models/question.go index 68d297c..3b6c30f 100644 --- a/models/question.go +++ b/models/question.go @@ -1,6 +1,27 @@ package models +import ( + "crypto/sha256" + "fmt" +) + type Question struct { Meta Text string `json:"text"` } + +func (q *Question) String() string { + return q.Text +} + +func (q *Question) GetID() string { + return q.ID +} + +func (q *Question) SetID(id string) { + q.ID = id +} + +func (q *Question) GetHash() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(q.Text))) +} diff --git a/models/quiz.go b/models/quiz.go index a12b812..7b556c8 100644 --- a/models/quiz.go +++ b/models/quiz.go @@ -1,11 +1,196 @@ package models +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "sort" + "strings" + + "gopkg.in/yaml.v2" +) + type Quiz struct { Meta - Hash string `json:"hash"` - Question *Question `json:"question" gorm:"foreignKey:ID"` - Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"` - Correct *Answer `json:"correct" gorm:"foreignKey:ID"` - Type int `json:"type"` + Hash string `json:"hash"` + Question *Question `json:"question" gorm:"foreignKey:ID"` + Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"` + Tags []*Tag `json:"tags" yaml:"-" gorm:"-"` + Correct *Answer `json:"correct" gorm:"foreignKey:ID"` + CorrectPos uint `gorm:"-"` // Position of the correct answer during quiz creation + Type int `json:"type"` +} + +func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) { + meta, remainingMarkdown, err := ParseMetaHeaderFromMarkdown(markdown) + if err != nil { + return nil, nil, err + } + + lines := strings.Split(remainingMarkdown, "\n") + + questionText := "" + answers := []*Answer{} + + for _, line := range lines { + if strings.HasPrefix(line, "*") { + answerText := strings.TrimPrefix(line, "* ") + answer := &Answer{Text: answerText} + answers = append(answers, answer) + } else { + if questionText != "" { + questionText += "\n" + } + questionText += line + } + } + + questionText = strings.TrimRight(questionText, "\n") + + if questionText == "" { + return nil, nil, fmt.Errorf("Question text should not be empty.") + } + + if len(answers) < 2 { + return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers)) + } + + question := &Question{Text: questionText} + quiz := &Quiz{Question: question, Answers: answers, CorrectPos: 0} + + if meta != nil { + quiz.Meta = *meta + } + + return quiz, meta, nil +} + +func QuizToMarkdown(quiz *Quiz) (string, error) { + if quiz.Question == nil { + return "", errors.New("Quiz should contain a question but it wasn't provided.") + } + + if len(quiz.Answers) < 2 { + return "", errors.New("Quiz should contain at least 2 answers but none was provided.") + } + + quiz.Correct = quiz.Answers[quiz.CorrectPos] + + if quiz.Correct == nil { + return "", errors.New("Quiz should contain a correct answer but not was provided.") + } + + correctAnswer := "* " + quiz.Correct.Text + var otherAnswers string + + for pos, answer := range quiz.Answers { + if quiz.CorrectPos != uint(pos) { + otherAnswers += "* " + answer.Text + "\n" + } + } + + markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers + + return markdown, nil +} + +func (q *Quiz) GetID() string { + return q.ID +} + +func (q *Quiz) SetID(id string) { + q.ID = id +} + +func (q *Quiz) GetHash() string { + return q.calculateHash() +} + +func (q *Quiz) calculateHash() string { + result := make([]string, 0) + + result = append(result, q.Question.GetHash()) + + for _, a := range q.Answers { + result = append(result, a.GetHash()) + } + + orderedHashes := make([]string, len(result)) + + copy(orderedHashes, result) + sort.Strings(orderedHashes) + + return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(orderedHashes, "")))) +} + +func ParseMetaHeaderFromMarkdown(markdown string) (*Meta, string, error) { + reader := strings.NewReader(markdown) + var sb strings.Builder + var line string + var err error + for { + line, err = readLine(reader) + if err != nil { + if err == io.EOF { + break + } + return nil, "", err + } + + if strings.TrimSpace(line) == "---" { + break + } + } + + for { + line, err = readLine(reader) + if err != nil { + if err == io.EOF { + break + } + return nil, "", err + } + + if strings.TrimSpace(line) == "---" { + break + } + + sb.WriteString(line) + } + + if sb.String() == "" { + return nil, markdown, nil + } + + var meta Meta + err = yaml.Unmarshal([]byte(sb.String()), &meta) + if err != nil { + return nil, markdown, err + } + + remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):] + + return &meta, remainingMarkdown, nil +} + +func readLine(reader *strings.Reader) (string, error) { + var sb strings.Builder + for { + r, _, err := reader.ReadRune() + if err != nil { + if err == io.EOF { + return sb.String(), io.EOF + } + return "", err + } + + sb.WriteRune(r) + if r == '\n' { + break + } + } + + return sb.String(), nil } diff --git a/store/collection_test.go b/store/collection_test.go new file mode 100644 index 0000000..64caeb0 --- /dev/null +++ b/store/collection_test.go @@ -0,0 +1,75 @@ +package store + +import ( + "git.andreafazzi.eu/andrea/probo/models" + "github.com/remogatto/prettytest" +) + +type collectionTestSuite struct { + prettytest.Suite +} + +func (t *collectionTestSuite) TestCreateCollection() { + quizStore := NewQuizStore() + quiz_1, _ := quizStore.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text #tag1 #tag3."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + }) + + quizStore.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text #tag2."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + }) + + quiz_2, _ := quizStore.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text #tag3."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + }) + + collectionStore := NewStore[*models.Collection]() + collection, err := collectionStore.Create( + &models.Collection{ + Name: "My Collection", + }) + t.Nil(err, "Collection should be created without error") + + if !t.Failed() { + quizzes := quizStore.FilterInCollection(collection, &models.Filter{ + Tags: []*models.Tag{ + {Name: "#tag1"}, + {Name: "#tag3"}, + }, + }) + + t.Equal(1, len(quizzes)) + + count := 0 + for _, q := range collection.Quizzes { + if quiz_1.ID == q.ID || quiz_2.ID == q.ID { + count++ + } + } + + t.Equal(1, count) + t.Equal(1, len(collection.Quizzes)) + } + +} diff --git a/store/file/.md b/store/file/.md deleted file mode 100644 index 356d066..0000000 --- a/store/file/.md +++ /dev/null @@ -1,7 +0,0 @@ -Newly created question text. - -* Answer 1 -* Answer 1 -* Answer 2 -* Answer 3 -* Answer 4 diff --git a/store/file/collection.go b/store/file/collection.go index b1cdb38..4b49015 100644 --- a/store/file/collection.go +++ b/store/file/collection.go @@ -2,159 +2,52 @@ package file import ( "encoding/json" - "errors" - "fmt" - "io/fs" "os" "path/filepath" - "strings" - "git.andreafazzi.eu/andrea/probo/client" "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" ) -func (s *FileProboCollectorStore) GetCollectionsDir() string { - return s.collectionsDir -} - -func (s *FileProboCollectorStore) GetCollectionPath(collection *models.Collection) (string, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - path, ok := s.collectionsPaths[collection.ID] - if !ok { - return "", errors.New(fmt.Sprintf("Path not found for collection ID %v", collection.ID)) - } - return path, nil -} - -func (s *FileProboCollectorStore) SetCollectionPath(collection *models.Collection, path string) string { - s.lock.Lock() - defer s.lock.Unlock() - - s.collectionsPaths[collection.ID] = path - - return path -} - -func (s *FileProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) { - return s.memoryStore.ReadAllCollections() -} - -func (s *FileProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) { - collection, err := s.memoryStore.CreateCollection(r) - if err != nil { - return nil, err - } - - err = s.createOrUpdateCollectionFile(collection) - if err != nil { - return nil, err - } - - return s.memoryStore.ReadCollectionByID(collection.ID) -} - -func (s *FileProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, error) { - collection, _, err := s.memoryStore.UpdateCollection(r, id) - if err != nil { - return nil, err - } - - err = s.createOrUpdateCollectionFile(collection) - if err != nil { - return nil, err - } - - return s.memoryStore.ReadCollectionByID(collection.ID) -} - -func (s *FileProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) { - collection, err := s.memoryStore.DeleteCollection(&client.DeleteCollectionRequest{ID: r.ID}) - if err != nil { - return nil, err - } - - path, err := s.GetCollectionPath(collection) - if err != nil { - return nil, err - } - - err = os.Remove(path) - if err != nil { - return nil, err - } - - return collection, nil -} - -func (s *FileProboCollectorStore) reindexCollections() error { - files, err := os.ReadDir(s.collectionsDir) - if err != nil { - return err - } - - jsonFiles := make([]fs.DirEntry, 0) - - for _, file := range files { - filename := file.Name() - if !file.IsDir() && strings.HasSuffix(filename, ".json") { - jsonFiles = append(jsonFiles, file) - } - } - - for _, file := range jsonFiles { - filename := file.Name() - fullPath := filepath.Join(s.collectionsDir, filename) - - content, err := os.ReadFile(fullPath) - if err != nil { - return err - } - - var clientCollection *client.Collection - - err = json.Unmarshal(content, &clientCollection) - if err != nil { - return err - } - - collection, err := s.memoryStore.CreateCollection(&client.CreateUpdateCollectionRequest{ - Collection: clientCollection, - }) - if err != nil { - return err - } - - s.SetCollectionPath(collection, fullPath) - } - - return nil -} - -func (s *FileProboCollectorStore) createOrUpdateCollectionFile(collection *models.Collection) error { - json, err := json.Marshal(collection) - if err != nil { - return err - } - - fn, _ := s.GetCollectionPath(collection) - if fn == "" { - fn = filepath.Join(s.collectionsDir, fmt.Sprintf("collection_%v.%s", collection.ID, "json")) - } - - file, err := os.Create(fn) - if err != nil { - return err - } - defer file.Close() - - _, err = file.Write([]byte(json)) - if err != nil { - return err - } - - s.SetCollectionPath(collection, fn) - - return nil +func NewCollectionFileStore() (*FileStore[*models.Collection, *store.Store[*models.Collection]], error) { + return NewFileStore[*models.Collection]( + store.NewStore[*models.Collection](), + filepath.Join(BaseDir, CollectionsDir), + "collection", + ".json", + func(s *store.Store[*models.Collection], filepath string, content []byte) (*models.Collection, error) { + collection := new(models.Collection) + err := json.Unmarshal(content, &collection) + if err != nil { + return nil, err + } + + c, err := s.Create(collection) + if err != nil { + return nil, err + } + + return c, nil + }, + func(s *store.Store[*models.Collection], filePath string, collection *models.Collection) error { + jsonData, err := json.Marshal(collection) + if err != nil { + return err + } + + file, err := os.Create(filePath) + if err != nil { + return err + } + + defer file.Close() + + _, err = file.Write(jsonData) + if err != nil { + return err + } + + return nil + }, + ) } diff --git a/store/file/collection_test.go b/store/file/collection_test.go index 1e6f3e1..3b31fdf 100644 --- a/store/file/collection_test.go +++ b/store/file/collection_test.go @@ -1,13 +1,10 @@ package file import ( - "encoding/json" - "fmt" "os" - "reflect" - "git.andreafazzi.eu/andrea/probo/client" "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" "github.com/remogatto/prettytest" ) @@ -16,158 +13,60 @@ type collectionTestSuite struct { } func (t *collectionTestSuite) TestCreateCollection() { - store, err := NewFileProboCollectorStore(testdataDir) - t.Nil(err, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) + quizStore := store.NewQuizStore() - if !t.Failed() { - quiz_1, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Question text with #tag1."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, + quizStore.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text #tag1 #tag3."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, }, }) - t.Nil(err, "The quiz to be updated should be created without issue") - - path_1, _ := store.GetQuizPath(quiz_1) - - if !t.Failed() { - quiz_2, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Another question text with #tag1."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - - t.Nil(err, "The quiz to be updated should be created without issue") - - path_2, _ := store.GetQuizPath(quiz_2) - - if !t.Failed() { - - quiz_3, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Question text without tags."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - - t.Nil(err, "The quiz to be updated should be created without issue") - - path_3, _ := store.GetQuizPath(quiz_3) - - if !t.Failed() { - - collection, err := store.CreateCollection( - &client.CreateUpdateCollectionRequest{ - Collection: &client.Collection{ - Name: "MyCollection", - Query: "#tag1", - }, - }) - - t.Nil(err, "Creating a collection should not return an error") - - collectionPath, _ := store.GetCollectionPath(collection) - - if !t.Failed() { - t.Equal(2, len(collection.Quizzes)) - - os.Remove(path_1) - os.Remove(path_2) - os.Remove(path_3) - os.Remove(collectionPath) - - } - } - } - } - } -} - -func (t *collectionTestSuite) TestUpdateCollection() { - store, err := NewFileProboCollectorStore(testdataDir) - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - if !t.Failed() { - collection, err := createCollectionOnDisk(store, &client.CreateUpdateCollectionRequest{ - Collection: &client.Collection{ - Name: "Collection name", - Query: "#tag1", + quizStore.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text #tag2."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, }, }) - t.Nil(err, "The collection to be updated should be created without issue") + quizStore.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text #tag3."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + }) - if !t.Failed() { - clientCollection := &client.Collection{ - Name: "Updated collection name", - Query: "#tag2", - } + store, err := NewCollectionFileStore() + t.Nil(err) - updatedCollection, err := store.UpdateCollection( - &client.CreateUpdateCollectionRequest{ - Collection: clientCollection, - }, collection.ID) + c := new(models.Collection) + c.Name = "MyCollection" - t.Nil(err, fmt.Sprintf("Collection should be updated without errors: %v", err)) + quizStore.FilterInCollection(c, &models.Filter{ + Tags: []*models.Tag{ + {Name: "#tag3"}, + }, + }) - t.Equal("#tag2", updatedCollection.Query) + _, err = store.Create(c) - if !t.Failed() { - path, err := store.GetCollectionPath(updatedCollection) + exists, err := os.Stat(store.GetPath(c)) - if !t.Failed() { - t.Nil(err, "GetPath should not raise an error.") - - if !t.Failed() { - - collectionFromDisk, err := readCollectionFromDisk(path) - t.Nil(err, fmt.Sprintf("Collection should be read from disk without errors but an issue was reported: %v", err)) - - if !t.Failed() { - t.True(reflect.DeepEqual(clientCollection, collectionFromDisk), "Collection should be updated.") - err := os.Remove(path) - t.Nil(err, "Stat should not return an error") - } - } - } - } - } - } + t.Nil(err) + t.Not(t.Nil(exists)) + t.Equal(2, len(c.Quizzes)) -} - -func createCollectionOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateCollectionRequest) (*models.Collection, error) { - return store.CreateCollection(req) - -} - -func readCollectionFromDisk(path string) (*client.Collection, error) { - content, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - collection := new(client.Collection) - err = json.Unmarshal(content, &collection) - if err != nil { - return nil, err - } - return collection, nil + defer os.Remove(store.GetPath(c)) } diff --git a/store/file/file.go b/store/file/file.go index 4c6264c..1dcc841 100644 --- a/store/file/file.go +++ b/store/file/file.go @@ -2,68 +2,176 @@ package file import ( "errors" + "fmt" + "io/fs" + "os" "path/filepath" + "strings" "sync" - "git.andreafazzi.eu/andrea/probo/hasher/sha256" - "git.andreafazzi.eu/andrea/probo/store/memory" + "git.andreafazzi.eu/andrea/probo/store" ) var ( ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.") - DefaultQuizzesDir = "quizzes" - DefaultCollectionsDir = "collections" + BaseDir = "data" + QuizzesDir = "quizzes" + CollectionsDir = "collections" ) -type FileProboCollectorStore struct { - Dir string - - memoryStore *memory.MemoryProboCollectorStore - - quizzesPaths map[string]string - collectionsPaths map[string]string - - quizzesDir string - collectionsDir string - - // A mutex is used to synchronize read/write access to the map - lock sync.RWMutex +type Storer[T store.Storable] interface { + store.Storer[T] + // store.FilterStorer[T] } -func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) { - s := new(FileProboCollectorStore) +type FileStore[T store.Storable, K Storer[T]] struct { + Storer K - s.Dir = dirname + Dir string + FilePrefix string + FileSuffix string - s.quizzesDir = filepath.Join(s.Dir, DefaultQuizzesDir) - s.collectionsDir = filepath.Join(s.Dir, DefaultCollectionsDir) + MarshalFunc func(K, string, []byte) (T, error) + UnmarshalFunc func(K, string, T) error - err := s.Reindex() + lock sync.RWMutex + paths map[string]string +} + +func NewFileStore[T store.Storable, K Storer[T]]( + storer K, + dir string, + prefix string, + suffix string, + marshalFunc func(K, string, []byte) (T, error), + unmarshalFunc func(K, string, T) error, +) (*FileStore[T, K], error) { + store := &FileStore[T, K]{ + Storer: storer, + Dir: dir, + FilePrefix: prefix, + FileSuffix: suffix, + MarshalFunc: marshalFunc, + UnmarshalFunc: unmarshalFunc, + paths: make(map[string]string, 0), + } + + err := store.IndexDir() if err != nil { return nil, err + } - return s, nil + return store, nil } -func (s *FileProboCollectorStore) Reindex() error { - s.memoryStore = memory.NewMemoryProboCollectorStore( - sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), - ) +func (s *FileStore[T, K]) Create(entity T) (T, error) { + e, err := s.Storer.Create(entity) + if err != nil { + return e, err + } - s.quizzesPaths = make(map[string]string) - s.collectionsPaths = make(map[string]string) + filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix)) - err := s.reindexQuizzes() + err = s.UnmarshalFunc(s.Storer, filePath, e) + if err != nil { + return e, err + } + + s.SetPath(e, filePath) + + return e, nil +} + +func (s *FileStore[T, K]) Update(entity T, id string) (T, error) { + e, err := s.Storer.Update(entity, id) + if err != nil { + return e, err + } + + filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix)) + + err = s.UnmarshalFunc(s.Storer, filePath, e) + if err != nil { + return e, err + } + + s.SetPath(e, filePath) + + return e, nil +} + +func (s *FileStore[T, K]) Read(id string) (T, error) { + return s.Storer.Read(id) +} + +func (s *FileStore[T, K]) ReadAll() []T { + return s.Storer.ReadAll() +} + +func (s *FileStore[T, K]) Delete(id string) (T, error) { + e, err := s.Storer.Delete(id) + if err != nil { + return e, err + } + + err = os.Remove(s.GetPath(e)) + if err != nil { + return e, err + } + + return e, nil +} + +func (s *FileStore[T, K]) IndexDir() error { + files, err := os.ReadDir(s.Dir) if err != nil { return err } - err = s.reindexCollections() - if err != nil { - return err + entityFiles := make([]fs.DirEntry, 0) + + for _, file := range files { + filename := file.Name() + if !file.IsDir() && strings.HasSuffix(filename, s.FileSuffix) { + entityFiles = append(entityFiles, file) + } + } + + for _, file := range entityFiles { + filename := file.Name() + fullPath := filepath.Join(s.Dir, filename) + + content, err := os.ReadFile(fullPath) + if err != nil { + return err + } + + entity, err := s.MarshalFunc(s.Storer, fullPath, content) + if err != nil { + return err + } + + s.SetPath(entity, fullPath) } return nil + +} + +func (s *FileStore[T, K]) GetPath(entity T) string { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.paths[entity.GetID()] +} + +func (s *FileStore[T, K]) SetPath(entity T, path string) string { + s.lock.Lock() + defer s.lock.Unlock() + + s.paths[entity.GetID()] = path + + return path } diff --git a/store/file/file_test.go b/store/file/file_test.go index 7f23a7e..919b550 100644 --- a/store/file/file_test.go +++ b/store/file/file_test.go @@ -1,24 +1,13 @@ package file import ( - "fmt" - "os" - "reflect" "testing" - "git.andreafazzi.eu/andrea/probo/client" - "git.andreafazzi.eu/andrea/probo/hasher/sha256" - "git.andreafazzi.eu/andrea/probo/models" - "git.andreafazzi.eu/andrea/probo/store/memory" "github.com/remogatto/prettytest" ) var testdataDir = "./testdata" -type quizTestSuite struct { - prettytest.Suite -} - func TestRunner(t *testing.T) { prettytest.Run( t, @@ -26,273 +15,3 @@ func TestRunner(t *testing.T) { new(collectionTestSuite), ) } - -func (t *quizTestSuite) TestQuizFromMarkdown() { - markdown := `Question text (1). - -Question text (2). - -Question text with #tag1 #tag2 (3). - -* Answer 1 -* Answer 2 -* Answer 3 -* Answer 4` - - expectedQuiz := &client.Quiz{ - Question: &client.Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - } - - quiz, _, err := QuizFromMarkdown(markdown) - t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err)) - - if !t.Failed() { - t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz)) - - } -} - -func (t *quizTestSuite) TestReadAllQuizzes() { - store, err := NewFileProboCollectorStore("./testdata/") - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - if !t.Failed() { - result, err := store.ReadAllQuizzes() - - t.True(err == nil, fmt.Sprintf("Quizzes should be returned without errors: %v", err)) - - if !t.Failed() { - t.Equal( - 4, - len(result), - fmt.Sprintf("The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v", len(result)), - ) - } - } - -} - -func (t *quizTestSuite) TestMarkdownFromQuiz() { - store := memory.NewMemoryProboCollectorStore(sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn)) - quiz, err := store.CreateQuiz( - &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Newly created question text."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - md, err := MarkdownFromQuiz(quiz) - t.Nil(err, "Conversion to markdown should not raise an error") - if !t.Failed() { - t.Equal(`Newly created question text. - -* Answer 1 -* Answer 2 -* Answer 3 -* Answer 4 -`, md) - } -} - -func (t *quizTestSuite) TestCreateQuiz() { - store, err := NewFileProboCollectorStore(testdataDir) - - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - if !t.Failed() { - clientQuiz := &client.Quiz{ - Question: &client.Question{Text: "Newly created question text."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - } - quiz, err := store.CreateQuiz( - &client.CreateUpdateQuizRequest{ - Quiz: clientQuiz, - }, - ) - - t.Nil(err, fmt.Sprintf("An error was raised when saving the quiz on disk: %v", err)) - - if !t.Failed() { - path, err := store.GetQuizPath(quiz) - t.Nil(err, "GetPath should not raise an error.") - - if !t.Failed() { - exists, err := os.Stat(path) - t.Nil(err, "Stat should not return an error") - - if !t.Failed() { - t.True(exists != nil, "The new quiz file was not created.") - if !t.Failed() { - quizFromDisk, _, err := readQuizFromDisk(path) - t.Nil(err, "Quiz should be read from disk without errors.") - if !t.Failed() { - t.True(reflect.DeepEqual(quizFromDisk, clientQuiz), "Quiz read from disk and stored in memory should be equal.") - err := os.Remove(path) - t.Nil(err, "Test file should be removed without errors.") - } - } - } - } - } - } -} - -func (t *quizTestSuite) TestDeleteQuiz() { - store, err := NewFileProboCollectorStore(testdataDir) - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - if !t.Failed() { - quiz, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "This quiz should be deleted."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - - t.Nil(err, "The quiz to be deleted should be created without issue") - - path, err := store.GetQuizPath(quiz) - t.True(path != "", "Quiz path should be obtained without errors") - - if !t.Failed() { - deletedQuiz, err := store.DeleteQuiz(&client.DeleteQuizRequest{ID: quiz.ID}) - - t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err)) - t.True(reflect.DeepEqual(quiz, deletedQuiz), "Quiz should be updateEd.") - - } - } -} - -func (t *quizTestSuite) TestUpdateQuiz() { - store, err := NewFileProboCollectorStore(testdataDir) - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - if !t.Failed() { - quiz, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Newly created question text."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - - t.Nil(err, "The quiz to be updated should be created without issue") - - if !t.Failed() { - clientQuiz := &client.Quiz{ - Question: &client.Question{Text: "Updated question text with #tag."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - } - - updatedQuiz, err := store.UpdateQuiz( - &client.CreateUpdateQuizRequest{ - Quiz: clientQuiz, - }, quiz.ID) - - t.Nil(err, fmt.Sprintf("Quiz should be updated without errors: %v", err)) - t.Equal(updatedQuiz.ID, quiz.ID, fmt.Sprintf("IDs should remain the same after an update: updated ID %v original ID %v", updatedQuiz.ID, quiz.ID)) - t.True(len(updatedQuiz.Tags) == 1, "Length of tags array should be 1") - - if !t.Failed() { - path, err := store.GetQuizPath(updatedQuiz) - - if !t.Failed() { - t.Nil(err, "GetPath should not raise an error.") - - if !t.Failed() { - quizFromDisk, _, err := readQuizFromDisk(path) - t.Nil(err, "Quiz should be read from disk without errors.") - if !t.Failed() { - t.True(reflect.DeepEqual(clientQuiz, quizFromDisk), "Quiz should be updated.") - err := os.Remove(path) - t.Nil(err, "Stat should not return an error") - } - } - } - } - } - } - -} - -func (t *quizTestSuite) TestReadMetaHeaderFromFile() { - store, err := NewFileProboCollectorStore(testdataDir) - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - meta, err := store.ReadMetaHeaderFromFile("quiz_4.md") - t.True(err == nil, fmt.Sprintf("An error occurred: %v", err)) - if !t.Failed() { - t.True(meta.ID != "") - t.True(meta.CreatedAt.String() != "") - } -} - -func (t *quizTestSuite) TestWriteMetaHeaderToFile() { - store, err := NewFileProboCollectorStore(testdataDir) - - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - if !t.Failed() { - meta, err := store.ReadMetaHeaderFromFile("quiz_5.md") - t.True(err == nil, fmt.Sprintf("Reading the header returns the following error: %v", err)) - if !t.Failed() { - t.True(meta != nil, "Meta header should not be nil") - - if !t.Failed() { - t.True(meta.ID != "", "ID should not be empty") - - if !t.Failed() { - _, err = store.removeMetaFromFile("quiz_5.md") - t.True(err == nil) - } - } - } - } -} - -func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) { - return store.CreateQuiz(req) - -} - -func readQuizFromDisk(path string) (*client.Quiz, *models.Meta, error) { - content, err := os.ReadFile(path) - if err != nil { - return nil, nil, err - } - return QuizFromMarkdown(string(content)) -} - -func testsAreEqual(got, want []*models.Quiz) bool { - return reflect.DeepEqual(got, want) -} diff --git a/store/file/quiz.go b/store/file/quiz.go index 545770e..e88a8cb 100644 --- a/store/file/quiz.go +++ b/store/file/quiz.go @@ -4,202 +4,109 @@ import ( "bufio" "bytes" "errors" - "fmt" "io" - "io/fs" "os" - "path" "path/filepath" "strings" "time" - "git.andreafazzi.eu/andrea/probo/client" "git.andreafazzi.eu/andrea/probo/models" - "github.com/go-yaml/yaml" + "git.andreafazzi.eu/andrea/probo/store" + "gopkg.in/yaml.v2" ) -func (s *FileProboCollectorStore) GetQuizzesDir() string { - return s.quizzesDir -} - -func (s *FileProboCollectorStore) SetQuizPath(quiz *models.Quiz, path string) string { - s.lock.Lock() - defer s.lock.Unlock() - - s.quizzesPaths[quiz.ID] = path - - return path -} - -func (s *FileProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { - return s.memoryStore.ReadAllQuizzes() -} - -func (s *FileProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) { - quiz, err := s.memoryStore.CreateQuiz(r) - if err != nil { - return nil, err - } - - err = s.createOrUpdateMarkdownFile(quiz) - if err != nil { - return nil, err - } - - err = s.Reindex() - if err != nil { - return nil, err - } - - return s.memoryStore.ReadQuizByHash(quiz.Hash) -} - -func (s *FileProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) { - quiz, updated, err := s.memoryStore.UpdateQuiz(r, id) - if err != nil { - return nil, err - } - - if updated { // Update and re-index only if quiz hash is changed - err = s.createOrUpdateMarkdownFile(quiz) - if err != nil { - return nil, err - } - - err = s.Reindex() - if err != nil { - return nil, err - } - } - return s.memoryStore.ReadQuizByHash(quiz.Hash) -} - -func (s *FileProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) { - quiz, err := s.memoryStore.DeleteQuiz(&client.DeleteQuizRequest{ID: r.ID}) - if err != nil { - return nil, err - } - - path, err := s.GetQuizPath(quiz) - if err != nil { - return nil, err - } - - err = os.Remove(path) - if err != nil { - return nil, err - } - - err = s.Reindex() - if err != nil { - return nil, err - } - - return quiz, nil -} - -func (s *FileProboCollectorStore) GetQuizPath(quiz *models.Quiz) (string, error) { - if quiz == nil { - return "", errors.New("Quiz object passed as argument is nil!") - } - - s.lock.RLock() - defer s.lock.RUnlock() - - path, ok := s.quizzesPaths[quiz.ID] - if !ok { - return "", errors.New(fmt.Sprintf("Path not found for quiz ID %v", quiz.ID)) - } - return path, nil -} - -func MarkdownFromQuiz(quiz *models.Quiz) (string, error) { - if quiz.Question == nil { - return "", errors.New("Quiz should contain a question but it wasn't provided.") - } - - if len(quiz.Answers) < 2 { - return "", errors.New("Quiz should contain at least 2 answers but none was provided.") - } - - if quiz.Correct == nil { - return "", errors.New("Quiz should contain a correct answer but not was provided.") - } - - correctAnswer := "* " + quiz.Correct.Text - var otherAnswers string - - for _, answer := range quiz.Answers { - if quiz.Correct.ID != answer.ID { - otherAnswers += "* " + answer.Text + "\n" - } - } - - markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers - - return markdown, nil -} - -func QuizFromMarkdown(markdown string) (*client.Quiz, *models.Meta, error) { - meta, remainingMarkdown, err := parseMetaHeaderFromMarkdown(markdown) - if err != nil { - return nil, nil, err - } - - lines := strings.Split(remainingMarkdown, "\n") - - questionText := "" - answers := []*client.Answer{} - - for _, line := range lines { - if strings.HasPrefix(line, "*") { - answerText := strings.TrimPrefix(line, "* ") - correct := len(answers) == 0 - answer := &client.Answer{Text: answerText, Correct: correct} - answers = append(answers, answer) - } else { - if questionText != "" { - questionText += "\n" +func NewQuizFileStore() (*FileStore[*models.Quiz, *store.QuizStore], error) { + return NewFileStore[*models.Quiz, *store.QuizStore]( + store.NewQuizStore(), + filepath.Join(BaseDir, QuizzesDir), + "quiz", + ".md", + func(s *store.QuizStore, filepath string, content []byte) (*models.Quiz, error) { + quiz, meta, err := models.MarkdownToQuiz(string(content)) + if err != nil { + return nil, err } - questionText += line - } - } - questionText = strings.TrimRight(questionText, "\n") + var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent - if questionText == "" { - return nil, nil, fmt.Errorf("Question text should not be empty.") - } + q, err := s.Create(quiz) + if err != nil && !errors.As(err, &errQuizAlreadyPresent) { + return nil, err + } - if len(answers) < 2 { - return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers)) - } + if meta == nil { + writeQuizHeader(filepath, &models.Meta{ + ID: q.ID, + CreatedAt: time.Now(), + }) + } - question := &client.Question{Text: questionText} - quiz := &client.Quiz{Question: question, Answers: answers} + return q, nil + }, + func(s *store.QuizStore, filePath string, quiz *models.Quiz) error { + markdown, err := models.QuizToMarkdown(quiz) + if err != nil { + return err + } - return quiz, meta, nil + file, err := os.Create(filePath) + if err != nil { + return err + } + + defer file.Close() + + markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta) + if err != nil { + return err + } + + _, err = file.Write([]byte(markdownWithMetaHeader)) + if err != nil { + return err + } + + return nil + }, + ) } -func (s *FileProboCollectorStore) ReadMetaHeaderFromFile(filename string) (*models.Meta, error) { - data, err := os.ReadFile(path.Join(s.quizzesDir, filename)) - if err != nil { - return nil, err - } - meta, _, err := parseMetaHeaderFromMarkdown(string(data)) - if err != nil { - return nil, err - } - return meta, nil -} - -func (s *FileProboCollectorStore) WriteMetaHeaderToFile(filename string, meta *models.Meta) (*models.Meta, error) { - readMeta, err := s.ReadMetaHeaderFromFile(filename) +func writeQuizHeader(path string, meta *models.Meta) (*models.Meta, error) { + readMeta, err := readQuizHeader(path) if err != nil { return nil, err } if readMeta == nil { - _, err := writeMetaHeader(path.Join(s.quizzesDir, filename), meta) + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var buffer bytes.Buffer + + header, err := yaml.Marshal(meta) + if err != nil { + return nil, err + } + + _, err = buffer.WriteString("---\n" + string(header) + "---\n") + if err != nil { + return nil, err + } + + _, err = io.Copy(&buffer, file) + if err != nil { + return nil, err + } + + file, err = os.Create(path) + if err != nil { + return nil, err + } + defer file.Close() + + _, err = io.Copy(file, &buffer) if err != nil { return nil, err } @@ -208,59 +115,40 @@ func (s *FileProboCollectorStore) WriteMetaHeaderToFile(filename string, meta *m return meta, nil } -func (s *FileProboCollectorStore) reindexQuizzes() error { - files, err := os.ReadDir(s.quizzesDir) +func readQuizHeader(path string) (*models.Meta, error) { + data, err := os.ReadFile(path) if err != nil { - return err + return nil, err } - - markdownFiles := make([]fs.DirEntry, 0) - - for _, file := range files { - filename := file.Name() - if !file.IsDir() && strings.HasSuffix(filename, ".md") { - markdownFiles = append(markdownFiles, file) - } + meta, _, err := models.ParseMetaHeaderFromMarkdown(string(data)) + if err != nil { + return nil, err } - - if len(markdownFiles) == 0 { - return fmt.Errorf("The directory is empty.") - } - - for _, file := range markdownFiles { - filename := file.Name() - fullPath := filepath.Join(s.quizzesDir, filename) - - content, err := os.ReadFile(fullPath) - if err != nil { - return err - } - quiz, meta, err := QuizFromMarkdown(string(content)) - if err != nil { - return err - } - q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{ - Quiz: quiz, - Meta: meta, - }) - if err != nil { - return err - } - - if meta == nil { - s.WriteMetaHeaderToFile(filename, &models.Meta{ - ID: q.ID, - CreatedAt: time.Now(), - }) - } - s.SetQuizPath(q, fullPath) - } - - return nil + return meta, nil } -func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) { - file, err := os.Open(path.Join(s.quizzesDir, filename)) +func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) { + var buffer bytes.Buffer + + header, err := yaml.Marshal(meta) + if err != nil { + return "", err + } + _, err = buffer.WriteString("---\n" + string(header) + "---\n") + if err != nil { + return "", err + } + + _, err = buffer.WriteString(content) + if err != nil { + return "", err + } + + return buffer.String(), nil +} + +func removeQuizHeader(path string) (*models.Meta, error) { + file, err := os.Open(path) if err != nil { return nil, err } @@ -313,7 +201,7 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M return nil, err } - file, err = os.Create(path.Join(s.quizzesDir, filename)) + file, err = os.Create(path) if err != nil { return nil, err } @@ -326,169 +214,3 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M return &meta, nil } - -func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error { - markdown, err := MarkdownFromQuiz(quiz) - if err != nil { - return err - } - - fn, _ := s.GetQuizPath(quiz) - if fn == "" { - fn = filepath.Join(s.quizzesDir, fmt.Sprintf("quiz_%v.%s", quiz.ID, "md")) - } - - file, err := os.Create(fn) - if err != nil { - return err - } - - defer file.Close() - - markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta) - if err != nil { - return err - } - - _, err = file.Write([]byte(markdownWithMetaHeader)) - if err != nil { - return err - } - - s.SetQuizPath(quiz, fn) - - return nil -} - -func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) { - var buffer bytes.Buffer - - header, err := yaml.Marshal(meta) - if err != nil { - return "", err - } - _, err = buffer.WriteString("---\n" + string(header) + "---\n") - if err != nil { - return "", err - } - - _, err = buffer.WriteString(content) - if err != nil { - return "", err - } - - return buffer.String(), nil -} - -func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error) { - reader := strings.NewReader(markdown) - var sb strings.Builder - var line string - var err error - for { - line, err = readLine(reader) - if err != nil { - if err == io.EOF { - break - } - return nil, "", err - } - - if strings.TrimSpace(line) == "---" { - break - } - } - - for { - line, err = readLine(reader) - if err != nil { - if err == io.EOF { - break - } - return nil, "", err - } - - if strings.TrimSpace(line) == "---" { - break - } - - sb.WriteString(line) - } - - if sb.String() == "" { - return nil, markdown, nil - } - - var meta models.Meta - err = yaml.Unmarshal([]byte(sb.String()), &meta) - if err != nil { - return nil, markdown, err - } - - remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):] - - return &meta, remainingMarkdown, nil -} - -func readLine(reader *strings.Reader) (string, error) { - var sb strings.Builder - for { - r, _, err := reader.ReadRune() - if err != nil { - if err == io.EOF { - return sb.String(), io.EOF - } - return "", err - } - - sb.WriteRune(r) - if r == '\n' { - break - } - } - - return sb.String(), nil -} - -func writeMetaHeader(filename string, meta *models.Meta) (*models.Meta, error) { - // Apri il file in lettura - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - - // Crea un buffer in memoria - var buffer bytes.Buffer - - // Scrivi l'intestazione YAML nel buffer - header, err := yaml.Marshal(meta) - if err != nil { - return nil, err - } - _, err = buffer.WriteString("---\n" + string(header) + "---\n") - if err != nil { - return nil, err - } - - // Copia il contenuto del file originale nel buffer - _, err = io.Copy(&buffer, file) - if err != nil { - return nil, err - } - - // Riapri il file in scrittura - file, err = os.Create(filename) - if err != nil { - return nil, err - } - defer file.Close() - - // Scrivi il contenuto del buffer nel file - _, err = io.Copy(file, &buffer) - if err != nil { - return nil, err - } - - return meta, nil -} diff --git a/store/file/quiz_test.go b/store/file/quiz_test.go new file mode 100644 index 0000000..32bdcd3 --- /dev/null +++ b/store/file/quiz_test.go @@ -0,0 +1,232 @@ +package file + +import ( + "fmt" + "os" + "path/filepath" + + "git.andreafazzi.eu/andrea/probo/models" + "github.com/remogatto/prettytest" +) + +type quizTestSuite struct { + prettytest.Suite +} + +func (t *quizTestSuite) BeforeAll() { + BaseDir = "testdata" +} + +func (t *quizTestSuite) TestReadAllQuizzes() { + store, err := NewQuizFileStore() + t.Nil(err) + + if !t.Failed() { + result := store.ReadAll() + + t.Equal( + 4, + len(result), + fmt.Sprintf( + "The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v", + len(result), + ), + ) + } +} + +func (t *quizTestSuite) TestCreateQuiz() { + store, err := NewQuizFileStore() + t.Nil(err) + + if !t.Failed() { + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err) + t.Equal(2, len(quiz.Tags)) + + if !t.Failed() { + path := store.GetPath(quiz) + t.True(path != "", "Path should not be empty.") + + exists, err := os.Stat(path) + t.Nil(err) + + if !t.Failed() { + t.True(exists != nil, "The new quiz file was not created.") + + if !t.Failed() { + quizFromDisk, _, err := readQuizFromDisk(path) + defer os.Remove(path) + + quizFromDisk.Correct = quiz.Answers[0] + quizFromDisk.Tags = quiz.Tags + + t.Nil(err) + + if !t.Failed() { + t.Equal(quizFromDisk.Question.Text, quiz.Question.Text) + for i, a := range quizFromDisk.Answers { + t.Equal(a.Text, quiz.Answers[i].Text) + } + for i, tag := range quizFromDisk.Tags { + t.Equal(tag.Name, quiz.Tags[i].Name) + } + } + } + } + + } + } +} + +func (t *quizTestSuite) TestDeleteQuiz() { + store, err := NewQuizFileStore() + t.Nil(err) + + if !t.Failed() { + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "This quiz should be deleted."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err) + if !t.Failed() { + path := store.GetPath(quiz) + _, err := store.Delete(quiz.ID) + t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err)) + if !t.Failed() { + _, err := os.Stat(path) + t.Not(t.Nil(err)) + + } + + } + } +} + +func (t *quizTestSuite) TestUpdateQuiz() { + store, err := NewQuizFileStore() + t.Nil(err) + + if !t.Failed() { + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err) + + _, err = store.Update(&models.Quiz{ + Question: &models.Question{Text: "Newly created question text with #tag1 #tag2 #tag3."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 1, + }, quiz.ID) + + t.Nil(err) + + updatedQuizFromMemory, err := store.Read(quiz.ID) + t.Equal(len(updatedQuizFromMemory.Tags), 3) + t.Equal("Answer 2", updatedQuizFromMemory.Correct.Text) + + defer os.Remove(store.GetPath(quiz)) + + } +} + +func (t *quizTestSuite) TestAutowriteHeader() { + store, err := NewQuizFileStore() + t.Nil(err) + + if !t.Failed() { + + meta, err := readQuizHeader(filepath.Join(store.Dir, "quiz_5.md")) + t.Nil(err) + + if !t.Failed() { + t.Not(t.Nil(meta)) + + if !t.Failed() { + t.True(meta.ID != "", "ID should not be empty") + + if !t.Failed() { + _, err = removeQuizHeader(filepath.Join(store.Dir, "quiz_5.md")) + t.True(err == nil) + } + } + } + } +} + +// func (t *quizTestSuite) TestReadMetaHeaderFromFile() { +// store, err := NewFileProboCollectorStore(testdataDir) +// t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) +// meta, err := store.ReadMetaHeaderFromFile("quiz_4.md") +// t.True(err == nil, fmt.Sprintf("An error occurred: %v", err)) +// if !t.Failed() { +// t.True(meta.ID != "") +// t.True(meta.CreatedAt.String() != "") +// } +// } + +// func (t *quizTestSuite) TestWriteMetaHeaderToFile() { +// store, err := NewFileProboCollectorStore(testdataDir) + +// t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) + +// if !t.Failed() { +// meta, err := store.ReadMetaHeaderFromFile("quiz_5.md") +// t.True(err == nil, fmt.Sprintf("Reading the header returns the following error: %v", err)) +// if !t.Failed() { +// t.True(meta != nil, "Meta header should not be nil") + +// if !t.Failed() { +// t.True(meta.ID != "", "ID should not be empty") + +// if !t.Failed() { +// _, err = store.removeMetaFromFile("quiz_5.md") +// t.True(err == nil) +// } +// } +// } +// } +// } + +// func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) { +// return store.CreateQuiz(req) + +// } + +func readQuizFromDisk(path string) (*models.Quiz, *models.Meta, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + return models.MarkdownToQuiz(string(content)) +} diff --git a/store/file/testdata/collections/collection_0386ac85-0701-48e7-a5a7-bf9713f63dac.json b/store/file/testdata/collections/collection_0386ac85-0701-48e7-a5a7-bf9713f63dac.json deleted file mode 100644 index f51b331..0000000 --- a/store/file/testdata/collections/collection_0386ac85-0701-48e7-a5a7-bf9713f63dac.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"0386ac85-0701-48e7-a5a7-bf9713f63dac","name":"Updated collection name","query":"#tag2","ids":[]} \ No newline at end of file diff --git a/store/file/testdata/collections/collection_b30c4392-52f3-46c5-8865-319ae7d1fbe0.json b/store/file/testdata/collections/collection_b30c4392-52f3-46c5-8865-319ae7d1fbe0.json deleted file mode 100644 index dc0c2b5..0000000 --- a/store/file/testdata/collections/collection_b30c4392-52f3-46c5-8865-319ae7d1fbe0.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"b30c4392-52f3-46c5-8865-319ae7d1fbe0","name":"Updated collection name","query":"#tag2","ids":[]} \ No newline at end of file diff --git a/store/file/testdata/quizzes/quiz_2.md b/store/file/testdata/quizzes/quiz_2.md index 4677398..6705d38 100644 --- a/store/file/testdata/quizzes/quiz_2.md +++ b/store/file/testdata/quizzes/quiz_2.md @@ -1,6 +1,7 @@ --- id: a09045c3-af87-4a83-a2bb-7283a2ac67d6 -created_at: !!timestamp 2023-09-22T09:08:50.366639817+02:00 +created_at: 2023-09-22T09:08:50.366639817+02:00 +updated_at: 0001-01-01T00:00:00Z --- Question text 2. @@ -8,4 +9,3 @@ Question text 2. * Answer 2 * Answer 3 * Answer 4 - diff --git a/store/file/testdata/quizzes/quiz_5.md b/store/file/testdata/quizzes/quiz_5.md index f073325..b2585dc 100644 --- a/store/file/testdata/quizzes/quiz_5.md +++ b/store/file/testdata/quizzes/quiz_5.md @@ -1,7 +1,7 @@ --- -id: b7ec3eb9-55e1-47c6-8652-3a27fe90bc0f -created_at: !!timestamp 2023-10-31T10:02:02.869395215+01:00 -updated_at: !!timestamp 0001-01-01T00:00:00Z +id: 5ebab010-8e96-41c9-905d-a092da037194 +created_at: 2023-11-13T20:58:30.651064703+01:00 +updated_at: 0001-01-01T00:00:00Z --- This quiz is initially without metadata. diff --git a/store/memory/memory.go b/store/memory/memory.go index 866414c..e036e38 100644 --- a/store/memory/memory.go +++ b/store/memory/memory.go @@ -1,31 +1,36 @@ package memory import ( - "errors" "sync" "git.andreafazzi.eu/andrea/probo/hasher" "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" ) -type MemoryProboCollectorStore struct { - - // IDs maps - quizzes map[string]*models.Quiz - collections map[string]*models.Collection - participants map[string]*models.Participant - - // Hashes maps - questionsHashes map[string]*models.Question - answersHashes map[string]*models.Answer - quizzesHashes map[string]*models.Quiz - - hasher hasher.Hasher +type Store[T Storable] struct { + ids map[string]T + hashes map[string]T // A mutex is used to synchronize read/write access to the map lock sync.RWMutex } +func NewStore[T store.Storable]() *Store[T] { + store := new(Store[T]) + + store.ids = make(map[string]T) + + return store +} + +type QuizStore struct { + *Store[*Quiz] + + questions *Store[*Question] + answers *Store[*Answer] +} + func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorStore { s := new(MemoryProboCollectorStore) @@ -41,19 +46,3 @@ func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorSto return s } - -func Create[T any](s *MemoryProboCollectorStore, elem *T) (*T, error) { - if elem == nil { - return nil, errors.New("A creation request was made passing a nil element") - } - - // Check for duplicates - hashes := s.hasher.QuizHashes(r.Quiz) - quizHash := hashes[len(hashes)-1] - - quiz := s.getQuizFromHash(quizHash) - if quiz != nil { // Quiz is already present in the store - return quiz, false, nil - } - -} diff --git a/store/quiz.go b/store/quiz.go new file mode 100644 index 0000000..0fb1b86 --- /dev/null +++ b/store/quiz.go @@ -0,0 +1,173 @@ +package store + +import ( + "fmt" + "strings" + + "git.andreafazzi.eu/andrea/probo/models" +) + +type ErrQuizAlreadyPresent struct { + hash string +} + +func (e *ErrQuizAlreadyPresent) Error() string { + return fmt.Sprintf("Quiz with hash %v is already present in the store.", e.hash) +} + +type QuizStore struct { + // Memory store for quizzes. It satisfies FilterStorer + // interface. + *FilterStore[*models.Quiz] + + questions *Store[*models.Question] + answers *Store[*models.Answer] +} + +func NewQuizStore() *QuizStore { + store := new(QuizStore) + + store.questions = NewStore[*models.Question]() + store.answers = NewStore[*models.Answer]() + store.FilterStore = NewFilterStore[*models.Quiz]() + + return store + +} + +func (s *QuizStore) Create(quiz *models.Quiz) (*models.Quiz, error) { + if hash := quiz.GetHash(); hash != "" { + q, ok := s.hashes[hash] + if ok { + return q, &ErrQuizAlreadyPresent{hash} + } + } + + question, err := s.questions.Create(quiz.Question) + if err != nil { + return nil, err + } + + answers := make([]*models.Answer, 0) + + for _, a := range quiz.Answers { + storedAnswer, err := s.answers.Create(a) + if err != nil { + return nil, err + } + answers = append(answers, storedAnswer) + } + + tags := make([]*models.Tag, 0) + + q, err := s.Store.Create(&models.Quiz{ + Meta: quiz.Meta, + Question: parseTags[*models.Question](&tags, question)[0], + Answers: parseTags[*models.Answer](&tags, answers...), + Correct: answers[quiz.CorrectPos], + CorrectPos: quiz.CorrectPos, + Tags: tags, + }) + if err != nil { + return nil, err + } + + return q, nil +} + +func (s *QuizStore) Update(quiz *models.Quiz, id string) (*models.Quiz, error) { + _, err := s.Read(id) + if err != nil { + return quiz, err + } + + question, err := s.questions.Create(quiz.Question) + if err != nil { + return nil, err + } + + answers := make([]*models.Answer, 0) + + for _, a := range quiz.Answers { + storedAnswer, err := s.answers.Create(a) + if err != nil { + return nil, err + } + answers = append(answers, storedAnswer) + } + + tags := make([]*models.Tag, 0) + + q, err := s.Store.Update(&models.Quiz{ + Question: parseTags[*models.Question](&tags, question)[0], + Answers: parseTags[*models.Answer](&tags, answers...), + Correct: answers[quiz.CorrectPos], + CorrectPos: quiz.CorrectPos, + Tags: tags, + }, id) + if err != nil { + return nil, err + } + + return q, nil +} + +func (s *QuizStore) FilterInCollection(collection *models.Collection, filter *models.Filter) []*models.Quiz { + quizzes := s.ReadAll() + filteredQuizzes := s.Filter(quizzes, func(q *models.Quiz) bool { + count := 0 + for _, qTag := range q.Tags { + if s.isTagInFilter(qTag, filter) { + count++ + } + } + if count == len(filter.Tags) { + return true + } + return false + }) + + collection.Quizzes = filteredQuizzes + + return collection.Quizzes +} + +func (s *QuizStore) isTagInFilter(tag *models.Tag, filter *models.Filter) bool { + for _, fTag := range filter.Tags { + if tag.Name == fTag.Name { + return true + } + } + return false +} + +func parseTags[T fmt.Stringer](tags *[]*models.Tag, entities ...T) []T { + for _, entity := range entities { + // Trim the following chars + trimChars := "*:.,/\\@()[]{}<>" + + // Split the text into words + words := strings.Fields(entity.String()) + + for _, word := range words { + // If the word starts with '#', it is considered as a tag + if strings.HasPrefix(word, "#") { + // Check if the tag already exists in the tags slice + exists := false + for _, tag := range *tags { + if tag.Name == word { + exists = true + break + } + } + + // If the tag does not exist in the tags slice, add it + if !exists { + *tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)}) + } + } + } + } + + return entities +} diff --git a/store/quiz_test.go b/store/quiz_test.go new file mode 100644 index 0000000..d24cb57 --- /dev/null +++ b/store/quiz_test.go @@ -0,0 +1,252 @@ +package store + +import ( + "reflect" + + "git.andreafazzi.eu/andrea/probo/models" + "github.com/remogatto/prettytest" +) + +type quizTestSuite struct { + prettytest.Suite +} + +func (t *quizTestSuite) TestCreateQuiz() { + store := NewQuizStore() + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text with #tag."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err, "Quiz should be created without error") + if !t.Failed() { + quizFromMemory, err := store.Read(quiz.GetID()) + t.Nil(err, "Quiz should be found in the store") + + if !t.Failed() { + t.True(quizFromMemory.GetID() != "") + t.Equal(len(quizFromMemory.Tags), 1) + t.Equal(len(store.questions.ids), 1) + t.Equal(len(store.answers.ids), 4) + + if !t.Failed() { + t.True(reflect.DeepEqual(quizFromMemory, quiz), "Quiz should be equal") + } + } + } + +} + +func (t *quizTestSuite) TestDuplicateQuiz() { + store := NewQuizStore() + _, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + + t.Nil(err, "Quiz 1 should be created without error") + + _, err = store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text."}, + Answers: []*models.Answer{ + {Text: "Answer 2"}, + {Text: "Answer 1"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 1, + }) + + t.Not(t.Nil(err), "Quiz 2 should not be created") + +} + +func (t *quizTestSuite) TestReadQuiz() { + store := NewQuizStore() + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + + t.Nil(err, "Quiz should be created without error") + + if !t.Failed() { + quizzes := store.ReadAll() + + t.Equal(1, len(quizzes)) + + storedQuiz, err := store.Read(quiz.GetID()) + + t.Nil(err, "Quiz should be read without error") + if !t.Failed() { + t.Equal(quiz.ID, storedQuiz.ID) + } + + } + +} + +func (t *quizTestSuite) TestUpdateQuiz() { + store := NewQuizStore() + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err, "Quiz should be created without error") + if !t.Failed() { + updatedQuiz, err := store.Update( + &models.Quiz{ + Question: &models.Question{Text: "Updated question text with #tag."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }, quiz.GetID()) + + updatedQuizFromMemory, err := store.Read(quiz.GetID()) + t.Nil(err, "Quiz should be found in the store") + + if !t.Failed() { + t.Equal(updatedQuizFromMemory.GetID(), updatedQuiz.GetID()) + t.Equal(updatedQuizFromMemory.Question.Text, updatedQuiz.Question.Text) + t.Equal(len(updatedQuizFromMemory.Tags), 1) + } + } + +} + +func (t *quizTestSuite) TestDeleteQuiz() { + store := NewQuizStore() + _, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err, "Quiz should be created without error") + if !t.Failed() { + quiz_2, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Newly created question text 2."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err, "Quiz should be created without error") + + if !t.Failed() { + _, err := store.Delete(quiz_2.GetID()) + t.Nil(err, "Quiz should be deleted without error") + t.Equal(1, len(store.ids)) + t.Equal(1, len(store.hashes)) + } + } +} + +func (t *quizTestSuite) TestFilter() { + store := NewQuizStore() + + quiz_1, _ := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text with #tag1."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2 with #tag2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + + quiz_2, _ := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text with #tag3."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2 with #tag4"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + + quizzes := store.Filter([]*models.Quiz{quiz_1, quiz_2}, func(q *models.Quiz) bool { + for _, t := range q.Tags { + if t.Name == "#tag1" { + return true + } + } + return false + }) + + t.Equal(1, len(quizzes)) +} + +func (t *quizTestSuite) TestParseTextForTags() { + store := NewQuizStore() + + quiz, err := store.Create( + &models.Quiz{ + Question: &models.Question{Text: "Question text with #tag1."}, + Answers: []*models.Answer{ + {Text: "Answer 1"}, + {Text: "Answer 2 with #tag2"}, + {Text: "Answer 3"}, + {Text: "Answer 4"}, + }, + CorrectPos: 0, + }) + t.Nil(err, "Quiz should be created without errors.") + + if !t.Failed() { + storedQuiz, err := store.Read(quiz.GetID()) + + t.Nil(err, "Quiz should be found in the store.") + t.Equal(2, len(storedQuiz.Tags)) + if !t.Failed() { + t.Equal("#tag1", storedQuiz.Tags[0].Name) + t.Equal("#tag2", storedQuiz.Tags[1].Name) + } + } + +} diff --git a/store/store.go b/store/store.go index e7253d5..6d8a903 100644 --- a/store/store.go +++ b/store/store.go @@ -1,60 +1,159 @@ package store import ( - "git.andreafazzi.eu/andrea/probo/client" - "git.andreafazzi.eu/andrea/probo/models" + "fmt" + "sync" + + "github.com/google/uuid" ) -type QuizReader interface { - ReadAllQuizzes() ([]*models.Quiz, error) - ReadQuizByID(id string) (*models.Quiz, error) - ReadQuizByHash(hash string) (*models.Quiz, error) +type IDer interface { + GetID() string + SetID(string) } -type QuizWriter interface { - CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) - UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) - DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) +type Hasher interface { + GetHash() string } -type CollectionReader interface { - ReadAllCollections() ([]*models.Collection, error) - ReadCollectionByID(id string) (*models.Collection, error) +type Storable interface { + IDer + Hasher } -type CollectionWriter interface { - CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) - UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) - DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) +type Storer[T Storable] interface { + Create(T) (T, error) + ReadAll() []T + Read(string) (T, error) + Update(T, string) (T, error) + Delete(string) (T, error) } -type ParticipantReader interface { - ReadAllParticipants() ([]*models.Participant, error) - ReadParticipantByID(id string) (*models.Participant, error) +type FilterStorer[T Storable] interface { + Storer[T] + + Filter([]T, func(T) bool) []T } -type ParticipantWriter interface { - CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error) - UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) - DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error) +type Store[T Storable] struct { + ids map[string]T + hashes map[string]T + + lock sync.RWMutex } -type ExamReader interface { - ReadAllExams() ([]*models.Exam, error) - ReadExamByID(id string) (*models.Exam, error) +type FilterStore[T Storable] struct { + *Store[T] } -type ExamWriter interface { - CreateExam(r *client.CreateUpdateExamRequest) (*models.Exam, error) - UpdateExam(r *client.CreateUpdateExamRequest, id string) (*models.Exam, bool, error) - DeleteExam(r *client.DeleteExamRequest) (*models.Exam, error) +func NewFilterStore[T Storable]() *FilterStore[T] { + return &FilterStore[T]{NewStore[T]()} } -type ProboCollectorStore interface { - QuizReader - QuizWriter - CollectionReader - CollectionWriter - ParticipantReader - ParticipantWriter +func (fs *FilterStore[T]) Filter(slice []T, f func(T) bool) []T { + result := make([]T, 0) + + for _, item := range slice { + if f(item) { + result = append(result, item) + } + } + + return result +} + +func NewStore[T Storable]() *Store[T] { + store := new(Store[T]) + + store.ids = make(map[string]T) + store.hashes = make(map[string]T) + + return store +} + +func (s *Store[T]) Create(entity T) (T, error) { + s.lock.Lock() + defer s.lock.Unlock() + + if hash := entity.GetHash(); hash != "" { + storedEntity, ok := s.hashes[hash] + if ok { + return storedEntity, nil + } + s.hashes[hash] = entity + } + + id := entity.GetID() + + if id == "" { + id = uuid.New().String() + } + + entity.SetID(id) + s.ids[id] = entity + + return entity, nil +} + +func (s *Store[T]) ReadAll() []T { + s.lock.Lock() + defer s.lock.Unlock() + + result := make([]T, 0) + + for _, v := range s.ids { + result = append(result, v) + } + + return result +} + +func (s *Store[T]) Read(id string) (T, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + entity, ok := s.ids[id] + if !ok { + return entity, fmt.Errorf("Entity with ID %s was not found in the store.", id) + } + + return entity, nil + +} + +func (s *Store[T]) Update(entity T, id string) (T, error) { + sEntity, err := s.Read(id) + if err != nil { + return sEntity, err + } + + s.lock.Lock() + defer s.lock.Unlock() + + entity.SetID(id) + s.ids[id] = entity + + if hash := entity.GetHash(); hash != "" { + s.hashes[hash] = entity + } + + return entity, nil +} + +func (s *Store[T]) Delete(id string) (T, error) { + sEntity, err := s.Read(id) + if err != nil { + return sEntity, err + } + + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.ids, id) + + if hash := sEntity.GetHash(); hash != "" { + delete(s.hashes, hash) + } + + return sEntity, nil } diff --git a/store/store_test.go b/store/store_test.go new file mode 100644 index 0000000..336bbcb --- /dev/null +++ b/store/store_test.go @@ -0,0 +1,15 @@ +package store + +import ( + "testing" + + "github.com/remogatto/prettytest" +) + +func TestRunner(t *testing.T) { + prettytest.Run( + t, + new(quizTestSuite), + new(collectionTestSuite), + ) +}