Ver código fonte

Generic refactoring almost completed

andrea 6 meses atrás
pai
commit
45bcf24ecf

+ 1 - 0
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
 )
 

+ 2 - 0
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=

+ 0 - 1
hasher/sha256/sha256.go

@@ -52,4 +52,3 @@ func (h *Default256Hasher) Calculate(hashes []string) string {
 
 	return h.hashFn(strings.Join(orderedHashes, ""))
 }
--

+ 21 - 0
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)))
+}

+ 22 - 2
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 ""
+}

+ 0 - 1
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:"-"`
 }

+ 58 - 0
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)
+	}
+}

+ 21 - 0
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)))
+}

+ 190 - 5
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
 }

+ 75 - 0
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))
+	}
+
+}

+ 0 - 7
store/file/.md

@@ -1,7 +0,0 @@
-Newly created question text.
-
-* Answer 1
-* Answer 1
-* Answer 2
-* Answer 3
-* Answer 4

+ 42 - 149
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
+		},
+	)
 }

+ 44 - 145
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))
-
-	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 := store.NewQuizStore()
+
+	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")
-
-		if !t.Failed() {
-			clientCollection := &client.Collection{
-				Name:  "Updated collection name",
-				Query: "#tag2",
-			}
-
-			updatedCollection, err := store.UpdateCollection(
-				&client.CreateUpdateCollectionRequest{
-					Collection: clientCollection,
-				}, collection.ID)
-
-			t.Nil(err, fmt.Sprintf("Collection should be updated without errors: %v", err))
-
-			t.Equal("#tag2", updatedCollection.Query)
-
-			if !t.Failed() {
-				path, err := store.GetCollectionPath(updatedCollection)
-
-				if !t.Failed() {
-					t.Nil(err, "GetPath should not raise an error.")
-
-					if !t.Failed() {
+	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"},
+			},
+		})
 
-						collectionFromDisk, err := readCollectionFromDisk(path)
-						t.Nil(err, fmt.Sprintf("Collection should be read from disk without errors but an issue was reported: %v", err))
+	store, err := NewCollectionFileStore()
+	t.Nil(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")
-						}
-					}
-				}
-			}
-		}
-	}
+	c := new(models.Collection)
+	c.Name = "MyCollection"
 
-}
+	quizStore.FilterInCollection(c, &models.Filter{
+		Tags: []*models.Tag{
+			{Name: "#tag3"},
+		},
+	})
 
-func createCollectionOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
-	return store.CreateCollection(req)
+	_, err = store.Create(c)
 
-}
+	exists, err := os.Stat(store.GetPath(c))
 
-func readCollectionFromDisk(path string) (*client.Collection, error) {
-	content, err := os.ReadFile(path)
-	if err != nil {
-		return nil, err
-	}
+	t.Nil(err)
+	t.Not(t.Nil(exists))
+	t.Equal(2, len(c.Quizzes))
 
-	collection := new(client.Collection)
-	err = json.Unmarshal(content, &collection)
-	if err != nil {
-		return nil, err
-	}
-	return collection, nil
+	defer os.Remove(store.GetPath(c))
 }

+ 138 - 30
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
+type Storer[T store.Storable] interface {
+	store.Storer[T]
+	// store.FilterStorer[T]
+}
 
-	memoryStore *memory.MemoryProboCollectorStore
+type FileStore[T store.Storable, K Storer[T]] struct {
+	Storer K
 
-	quizzesPaths     map[string]string
-	collectionsPaths map[string]string
+	Dir        string
+	FilePrefix string
+	FileSuffix string
 
-	quizzesDir     string
-	collectionsDir string
+	MarshalFunc   func(K, string, []byte) (T, error)
+	UnmarshalFunc func(K, string, T) error
 
-	// A mutex is used to synchronize read/write access to the map
-	lock sync.RWMutex
+	lock  sync.RWMutex
+	paths map[string]string
 }
 
-func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) {
-	s := new(FileProboCollectorStore)
+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 store, nil
+}
 
-	s.Dir = dirname
+func (s *FileStore[T, K]) Create(entity T) (T, error) {
+	e, err := s.Storer.Create(entity)
+	if err != nil {
+		return e, err
+	}
 
-	s.quizzesDir = filepath.Join(s.Dir, DefaultQuizzesDir)
-	s.collectionsDir = filepath.Join(s.Dir, DefaultCollectionsDir)
+	filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix))
 
-	err := s.Reindex()
+	err = s.UnmarshalFunc(s.Storer, filePath, e)
 	if err != nil {
-		return nil, err
+		return e, err
 	}
 
-	return s, nil
+	s.SetPath(e, filePath)
+
+	return e, nil
 }
 
-func (s *FileProboCollectorStore) Reindex() error {
-	s.memoryStore = memory.NewMemoryProboCollectorStore(
-		sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
-	)
+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
+	}
 
-	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 err
+		return e, err
 	}
 
-	err = s.reindexCollections()
+	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
 	}
 
+	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
 }

+ 0 - 281
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)
-}

+ 88 - 366
store/file/quiz.go

@@ -4,360 +4,127 @@ 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 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
+			}
 
-func QuizFromMarkdown(markdown string) (*client.Quiz, *models.Meta, error) {
-	meta, remainingMarkdown, err := parseMetaHeaderFromMarkdown(markdown)
-	if err != nil {
-		return nil, nil, err
-	}
+			var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent
 
-	lines := strings.Split(remainingMarkdown, "\n")
+			q, err := s.Create(quiz)
+			if err != nil && !errors.As(err, &errQuizAlreadyPresent) {
+				return nil, err
+			}
 
-	questionText := ""
-	answers := []*client.Answer{}
+			if meta == nil {
+				writeQuizHeader(filepath, &models.Meta{
+					ID:        q.ID,
+					CreatedAt: time.Now(),
+				})
+			}
 
-	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"
+			return q, nil
+		},
+		func(s *store.QuizStore, filePath string, quiz *models.Quiz) error {
+			markdown, err := models.QuizToMarkdown(quiz)
+			if err != nil {
+				return err
 			}
-			questionText += line
-		}
-	}
 
-	questionText = strings.TrimRight(questionText, "\n")
+			file, err := os.Create(filePath)
+			if err != nil {
+				return err
+			}
 
-	if questionText == "" {
-		return nil, nil, fmt.Errorf("Question text should not be empty.")
-	}
+			defer file.Close()
 
-	if len(answers) < 2 {
-		return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
-	}
+			markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta)
+			if err != nil {
+				return err
+			}
 
-	question := &client.Question{Text: questionText}
-	quiz := &client.Quiz{Question: question, Answers: answers}
+			_, err = file.Write([]byte(markdownWithMetaHeader))
+			if err != nil {
+				return err
+			}
 
-	return quiz, meta, nil
+			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()
 
-	return meta, nil
-}
-
-func (s *FileProboCollectorStore) reindexQuizzes() error {
-	files, err := os.ReadDir(s.quizzesDir)
-	if err != nil {
-		return err
-	}
-
-	markdownFiles := make([]fs.DirEntry, 0)
-
-	for _, file := range files {
-		filename := file.Name()
-		if !file.IsDir() && strings.HasSuffix(filename, ".md") {
-			markdownFiles = append(markdownFiles, file)
-		}
-	}
-
-	if len(markdownFiles) == 0 {
-		return fmt.Errorf("The directory is empty.")
-	}
+		var buffer bytes.Buffer
 
-	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))
+		header, err := yaml.Marshal(meta)
 		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(),
-			})
+			return nil, err
 		}
-		s.SetQuizPath(q, fullPath)
-	}
-
-	return nil
-}
-
-func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) {
-	file, err := os.Open(path.Join(s.quizzesDir, filename))
-	if err != nil {
-		return nil, err
-	}
-	defer file.Close()
-
-	var buffer bytes.Buffer
 
-	reader := bufio.NewReader(file)
-
-	var meta models.Meta
-	var line string
-	var sb strings.Builder
-	for {
-		line, err = reader.ReadString('\n')
+		_, err = buffer.WriteString("---\n" + string(header) + "---\n")
 		if err != nil {
-			if err == io.EOF {
-				break
-			}
 			return nil, err
 		}
 
-		if strings.TrimSpace(line) == "---" {
-			break
+		_, err = io.Copy(&buffer, file)
+		if err != nil {
+			return nil, err
 		}
-	}
 
-	for {
-		line, err = reader.ReadString('\n')
+		file, err = os.Create(path)
 		if err != nil {
-			if err == io.EOF {
-				break
-			}
 			return nil, err
 		}
+		defer file.Close()
 
-		if strings.TrimSpace(line) == "---" {
-			break
+		_, err = io.Copy(file, &buffer)
+		if err != nil {
+			return nil, err
 		}
-
-		sb.WriteString(line)
 	}
 
-	err = yaml.Unmarshal([]byte(sb.String()), &meta)
-	if err != nil {
-		return nil, err
-	}
-
-	_, err = io.Copy(&buffer, reader)
-	if err != nil {
-		return nil, err
-	}
+	return meta, nil
+}
 
-	file, err = os.Create(path.Join(s.quizzesDir, filename))
+func readQuizHeader(path string) (*models.Meta, error) {
+	data, err := os.ReadFile(path)
 	if err != nil {
 		return nil, err
 	}
-	defer file.Close()
-
-	_, err = io.Copy(file, &buffer)
+	meta, _, err := models.ParseMetaHeaderFromMarkdown(string(data))
 	if err != nil {
 		return nil, err
 	}
-
-	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
+	return meta, nil
 }
 
 func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) {
@@ -380,18 +147,27 @@ func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error)
 	return buffer.String(), nil
 }
 
-func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error) {
-	reader := strings.NewReader(markdown)
-	var sb strings.Builder
+func removeQuizHeader(path string) (*models.Meta, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	var buffer bytes.Buffer
+
+	reader := bufio.NewReader(file)
+
+	var meta models.Meta
 	var line string
-	var err error
+	var sb strings.Builder
 	for {
-		line, err = readLine(reader)
+		line, err = reader.ReadString('\n')
 		if err != nil {
 			if err == io.EOF {
 				break
 			}
-			return nil, "", err
+			return nil, err
 		}
 
 		if strings.TrimSpace(line) == "---" {
@@ -400,12 +176,12 @@ func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error)
 	}
 
 	for {
-		line, err = readLine(reader)
+		line, err = reader.ReadString('\n')
 		if err != nil {
 			if err == io.EOF {
 				break
 			}
-			return nil, "", err
+			return nil, err
 		}
 
 		if strings.TrimSpace(line) == "---" {
@@ -415,80 +191,26 @@ func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error)
 		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)
+	_, err = io.Copy(&buffer, reader)
 	if err != nil {
 		return nil, err
 	}
 
-	// Riapri il file in scrittura
-	file, err = os.Create(filename)
+	file, err = os.Create(path)
 	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
+	return &meta, nil
 }

+ 232 - 0
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))
+}

+ 0 - 1
store/file/testdata/collections/collection_0386ac85-0701-48e7-a5a7-bf9713f63dac.json

@@ -1 +0,0 @@
-{"id":"0386ac85-0701-48e7-a5a7-bf9713f63dac","name":"Updated collection name","query":"#tag2","ids":[]}

+ 0 - 1
store/file/testdata/collections/collection_b30c4392-52f3-46c5-8865-319ae7d1fbe0.json

@@ -1 +0,0 @@
-{"id":"b30c4392-52f3-46c5-8865-319ae7d1fbe0","name":"Updated collection name","query":"#tag2","ids":[]}

+ 2 - 2
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
-

+ 3 - 3
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.
 

+ 18 - 29
store/memory/memory.go

@@ -1,29 +1,34 @@
 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 {
+type Store[T Storable] struct {
+	ids    map[string]T
+	hashes map[string]T
 
-	// IDs maps
-	quizzes      map[string]*models.Quiz
-	collections  map[string]*models.Collection
-	participants map[string]*models.Participant
+	// A mutex is used to synchronize read/write access to the map
+	lock sync.RWMutex
+}
 
-	// Hashes maps
-	questionsHashes map[string]*models.Question
-	answersHashes   map[string]*models.Answer
-	quizzesHashes   map[string]*models.Quiz
+func NewStore[T store.Storable]() *Store[T] {
+	store := new(Store[T])
 
-	hasher hasher.Hasher
+	store.ids = make(map[string]T)
 
-	// A mutex is used to synchronize read/write access to the map
-	lock sync.RWMutex
+	return store
+}
+
+type QuizStore struct {
+	*Store[*Quiz]
+
+	questions *Store[*Question]
+	answers   *Store[*Answer]
 }
 
 func NewMemoryProboCollectorStore(hasher hasher.Hasher) *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
-	}
-
-}

+ 173 - 0
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
+}

+ 252 - 0
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)
+		}
+	}
+
+}

+ 137 - 38
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 Hasher interface {
+	GetHash() string
+}
+
+type Storable interface {
+	IDer
+	Hasher
 }
 
-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 Storer[T Storable] interface {
+	Create(T) (T, error)
+	ReadAll() []T
+	Read(string) (T, error)
+	Update(T, string) (T, error)
+	Delete(string) (T, error)
 }
 
-type CollectionReader interface {
-	ReadAllCollections() ([]*models.Collection, error)
-	ReadCollectionByID(id string) (*models.Collection, error)
+type FilterStorer[T Storable] interface {
+	Storer[T]
+
+	Filter([]T, func(T) bool) []T
 }
 
-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 Store[T Storable] struct {
+	ids    map[string]T
+	hashes map[string]T
+
+	lock sync.RWMutex
+}
+
+type FilterStore[T Storable] struct {
+	*Store[T]
+}
+
+func NewFilterStore[T Storable]() *FilterStore[T] {
+	return &FilterStore[T]{NewStore[T]()}
+}
+
+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
 }
 
-type ParticipantReader interface {
-	ReadAllParticipants() ([]*models.Participant, error)
-	ReadParticipantByID(id string) (*models.Participant, error)
+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
 }
 
-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)
+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
 }
 
-type ExamReader interface {
-	ReadAllExams() ([]*models.Exam, error)
-	ReadExamByID(id string) (*models.Exam, error)
+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
+
 }
 
-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 (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
 }
 
-type ProboCollectorStore interface {
-	QuizReader
-	QuizWriter
-	CollectionReader
-	CollectionWriter
-	ParticipantReader
-	ParticipantWriter
+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
 }

+ 15 - 0
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),
+	)
+}