Add metadata headers

This commit is contained in:
andrea 2023-09-22 10:29:10 +02:00
parent 9780956432
commit 4f3ecda14c
13 changed files with 358 additions and 21 deletions

View file

@ -46,6 +46,7 @@ type CreateAnswerRequest struct {
type CreateUpdateQuizRequest struct { type CreateUpdateQuizRequest struct {
*Quiz *Quiz
*models.Meta
} }
type DeleteQuizRequest struct { type DeleteQuizRequest struct {

1
go.mod
View file

@ -5,6 +5,7 @@ go 1.17
require github.com/sirupsen/logrus v1.8.1 require github.com/sirupsen/logrus v1.8.1
require ( require (
github.com/go-yaml/yaml v2.1.0+incompatible // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect
github.com/kr/pretty v0.2.1 // indirect github.com/kr/pretty v0.2.1 // indirect

2
go.sum
View file

@ -1,4 +1,6 @@
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=

8
models/meta.go Normal file
View file

@ -0,0 +1,8 @@
package models
import "time"
type Meta struct {
ID string `json:"id" yaml:"id"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
}

View file

@ -1,7 +1,8 @@
package models package models
type Quiz struct { type Quiz struct {
ID string `json:"id"` Meta
// ID string `json:"id"`
Hash string `json:"hash"` Hash string `json:"hash"`
Question *Question `json:"question"` Question *Question `json:"question"`
Answers []*Answer `json:"answers"` Answers []*Answer `json:"answers"`

View file

@ -1,11 +1,15 @@
package file package file
import ( import (
"bufio"
"bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"io/ioutil" "io/ioutil"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@ -15,8 +19,11 @@ import (
"git.andreafazzi.eu/andrea/probo/hasher/sha256" "git.andreafazzi.eu/andrea/probo/hasher/sha256"
"git.andreafazzi.eu/andrea/probo/models" "git.andreafazzi.eu/andrea/probo/models"
"git.andreafazzi.eu/andrea/probo/store/memory" "git.andreafazzi.eu/andrea/probo/store/memory"
"github.com/go-yaml/yaml"
) )
var ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
type FileProboCollectorStore struct { type FileProboCollectorStore struct {
Dir string Dir string
@ -73,17 +80,25 @@ func (s *FileProboCollectorStore) Reindex() error {
if err != nil { if err != nil {
return err return err
} }
quiz, err := QuizFromMarkdown(string(content)) quiz, meta, err := QuizFromMarkdown(string(content))
if err != nil { if err != nil {
return err return err
} }
q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{ q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{
Quiz: quiz, Quiz: quiz,
Meta: meta,
}) })
if err != nil { if err != nil {
return err return err
} }
if meta == nil {
s.WriteMetaHeaderToFile(filename, &models.Meta{
ID: q.ID,
CreatedAt: time.Now(),
})
}
s.SetPath(q, fullPath) s.SetPath(q, fullPath)
} }
@ -202,8 +217,13 @@ func MarkdownFromQuiz(quiz *models.Quiz) (string, error) {
return markdown, nil return markdown, nil
} }
func QuizFromMarkdown(markdown string) (*client.Quiz, error) { func QuizFromMarkdown(markdown string) (*client.Quiz, *models.Meta, error) {
lines := strings.Split(markdown, "\n") meta, remainingMarkdown, err := parseMetaHeaderFromMarkdown(markdown)
if err != nil {
return nil, nil, err
}
lines := strings.Split(remainingMarkdown, "\n")
questionText := "" questionText := ""
answers := []*client.Answer{} answers := []*client.Answer{}
@ -225,17 +245,113 @@ func QuizFromMarkdown(markdown string) (*client.Quiz, error) {
questionText = strings.TrimRight(questionText, "\n") questionText = strings.TrimRight(questionText, "\n")
if questionText == "" { if questionText == "" {
return nil, fmt.Errorf("Question text should not be empty.") return nil, nil, fmt.Errorf("Question text should not be empty.")
} }
if len(answers) < 2 { if len(answers) < 2 {
return nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers)) return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
} }
question := &client.Question{Text: questionText} question := &client.Question{Text: questionText}
quiz := &client.Quiz{Question: question, Answers: answers} quiz := &client.Quiz{Question: question, Answers: answers}
return quiz, nil return quiz, meta, nil
}
func (s *FileProboCollectorStore) ReadMetaHeaderFromFile(filename string) (*models.Meta, error) {
data, err := ioutil.ReadFile(path.Join(s.Dir, 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)
if err != nil {
return nil, err
}
if readMeta == nil {
_, err := writeMetaHeader(path.Join(s.Dir, filename), meta)
if err != nil {
return nil, err
}
}
return meta, nil
}
func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) {
file, err := os.Open(path.Join(s.Dir, 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')
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
if strings.TrimSpace(line) == "---" {
break
}
}
for {
line, err = reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
if strings.TrimSpace(line) == "---" {
break
}
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
}
file, err = os.Create(path.Join(s.Dir, filename))
if err != nil {
return nil, err
}
defer file.Close()
_, err = io.Copy(file, &buffer)
if err != nil {
return nil, err
}
return &meta, nil
} }
func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error { func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error {
@ -253,10 +369,14 @@ func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz)
if err != nil { if err != nil {
return err return err
} }
defer file.Close() defer file.Close()
_, err = file.Write([]byte(markdown)) markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta)
if err != nil {
return err
}
_, err = file.Write([]byte(markdownWithMetaHeader))
if err != nil { if err != nil {
return err return err
} }
@ -265,3 +385,136 @@ func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz)
return nil 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
}

View file

@ -46,7 +46,7 @@ Question text (3).
}, },
} }
quiz, err := QuizFromMarkdown(markdown) quiz, _, err := QuizFromMarkdown(markdown)
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err)) t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
if !t.Failed() { if !t.Failed() {
@ -65,9 +65,9 @@ func (t *testSuite) TestReadAllQuizzes() {
if !t.Failed() { if !t.Failed() {
t.Equal( t.Equal(
2, 4,
len(result), len(result),
fmt.Sprintf("The store contains 3 files but only 2 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v", 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)),
) )
} }
} }
@ -136,7 +136,7 @@ func (t *testSuite) TestCreateQuiz() {
if !t.Failed() { if !t.Failed() {
t.True(exists != nil, "The new quiz file was not created.") t.True(exists != nil, "The new quiz file was not created.")
if !t.Failed() { if !t.Failed() {
quizFromDisk, err := readQuizFromDisk(path) quizFromDisk, _, err := readQuizFromDisk(path)
t.Nil(err, "Quiz should be read from disk without errors.") t.Nil(err, "Quiz should be read from disk without errors.")
if !t.Failed() { if !t.Failed() {
t.True(reflect.DeepEqual(quizFromDisk, clientQuiz), "Quiz read from disk and stored in memory should be equal.") t.True(reflect.DeepEqual(quizFromDisk, clientQuiz), "Quiz read from disk and stored in memory should be equal.")
@ -205,7 +205,7 @@ func (t *testSuite) TestUpdateQuiz() {
if !t.Failed() { if !t.Failed() {
clientQuiz := &client.Quiz{ clientQuiz := &client.Quiz{
Question: &client.Question{Text: "Newly created question text."}, Question: &client.Question{Text: "Updated question text."},
Answers: []*client.Answer{ Answers: []*client.Answer{
{Text: "Answer 1", Correct: true}, {Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false}, {Text: "Answer 2", Correct: false},
@ -220,7 +220,7 @@ func (t *testSuite) TestUpdateQuiz() {
}, quiz.ID) }, quiz.ID)
t.Nil(err, fmt.Sprintf("Quiz should be updated without errors: %v", err)) t.Nil(err, fmt.Sprintf("Quiz should be updated without errors: %v", err))
// t.Equal(updatedQuiz.ID, quiz.ID, "Quiz ID should remain the same") 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))
if !t.Failed() { if !t.Failed() {
path, err := store.GetPath(updatedQuiz) path, err := store.GetPath(updatedQuiz)
@ -229,7 +229,7 @@ func (t *testSuite) TestUpdateQuiz() {
t.Nil(err, "GetPath should not raise an error.") t.Nil(err, "GetPath should not raise an error.")
if !t.Failed() { if !t.Failed() {
quizFromDisk, err := readQuizFromDisk(path) quizFromDisk, _, err := readQuizFromDisk(path)
t.Nil(err, "Quiz should be read from disk without errors.") t.Nil(err, "Quiz should be read from disk without errors.")
if !t.Failed() { if !t.Failed() {
t.True(reflect.DeepEqual(clientQuiz, quizFromDisk), "Quiz should be updated.") t.True(reflect.DeepEqual(clientQuiz, quizFromDisk), "Quiz should be updated.")
@ -244,15 +244,52 @@ func (t *testSuite) TestUpdateQuiz() {
} }
func (t *testSuite) TestReadMetaHeaderFromFile() {
dirname := "./testdata/quizzes"
store, err := NewFileProboCollectorStore(dirname)
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 *testSuite) TestWriteMetaHeaderToFile() {
dirname := "./testdata/quizzes"
store, err := NewFileProboCollectorStore(dirname)
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)
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) { func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
return store.CreateQuiz(req) return store.CreateQuiz(req)
} }
func readQuizFromDisk(path string) (*client.Quiz, error) { func readQuizFromDisk(path string) (*client.Quiz, *models.Meta, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
return QuizFromMarkdown(string(content)) return QuizFromMarkdown(string(content))
} }

View file

@ -1,3 +1,7 @@
---
id: 42982769-2168-45e8-b307-8764c18bec55
created_at: !!timestamp 2023-09-22T09:07:47.744571823+02:00
---
Question text 1. Question text 1.
* Answer 1 * Answer 1

View file

@ -1,3 +1,7 @@
---
id: a09045c3-af87-4a83-a2bb-7283a2ac67d6
created_at: !!timestamp 2023-09-22T09:08:50.366639817+02:00
---
Question text 2. Question text 2.
* Answer 1 * Answer 1

View file

@ -1,3 +1,7 @@
---
id: a09045c3-af87-4a83-a2bb-7283a2ac67d6
created_at: !!timestamp 2023-09-22T09:08:50.366772426+02:00
---
Question text 2. Question text 2.
* Answer 1 * Answer 1

10
store/file/testdata/quizzes/quiz_4.md vendored Normal file
View file

@ -0,0 +1,10 @@
---
id: 0000-1234-5678-9101
created_at: 2006-01-02T15:04:05Z
---
This quiz file contains a meta header.
* Answer 1
* Answer 2
* Answer 3
* Answer 4

7
store/file/testdata/quizzes/quiz_5.md vendored Normal file
View file

@ -0,0 +1,7 @@
This quiz is initially without metadata.
* Answer 1
* Answer 2
* Answer 3
* Answer 4

View file

@ -154,7 +154,6 @@ func (s *MemoryProboCollectorStore) CalculateQuizHash(quiz *client.Quiz) string
} }
func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) { func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) {
hashes := s.hasher.QuizHashes(r.Quiz) hashes := s.hasher.QuizHashes(r.Quiz)
quizHash := hashes[len(hashes)-1] quizHash := hashes[len(hashes)-1]
@ -163,13 +162,19 @@ func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQui
return quiz, false, nil return quiz, false, nil
} }
if id != "" { if id != "" { // we're updating a quiz
quiz = s.getQuizFromID(id) quiz = s.getQuizFromID(id)
if quiz == nil { // Quiz is not present in the store if quiz == nil { // Quiz is not present in the store
return nil, false, fmt.Errorf("Quiz ID %v doesn't exist in the store!", id) return nil, false, fmt.Errorf("Quiz ID %v doesn't exist in the store!", id)
} }
} else {
if r.Meta != nil {
if r.Meta.ID != "" {
id = r.Meta.ID
}
} else { } else {
id = uuid.New().String() id = uuid.New().String()
}
quiz = new(models.Quiz) quiz = new(models.Quiz)
} }