package file import ( "errors" "fmt" "io/fs" "io/ioutil" "os" "path/filepath" "strings" "sync" "time" "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" ) type FileProboCollectorStore struct { Dir string memoryStore *memory.MemoryProboCollectorStore paths map[string]string // A mutex is used to synchronize read/write access to the map lock sync.RWMutex } func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) { s := new(FileProboCollectorStore) s.Dir = dirname err := s.Reindex() if err != nil { return nil, err } return s, nil } func (s *FileProboCollectorStore) Reindex() error { files, err := ioutil.ReadDir(s.Dir) if err != nil { return err } s.paths = make(map[string]string) markdownFiles := make([]fs.FileInfo, 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.") } s.memoryStore = memory.NewMemoryProboCollectorStore( sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), ) for _, file := range markdownFiles { filename := file.Name() fullPath := filepath.Join(s.Dir, filename) content, err := os.ReadFile(fullPath) if err != nil { return err } quiz, err := QuizFromMarkdown(string(content)) if err != nil { return err } q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{ Quiz: quiz, }) if err != nil { return err } s.SetPath(q, fullPath) } return nil } 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) GetPath(quiz *models.Quiz) (string, error) { s.lock.RLock() defer s.lock.RUnlock() path, ok := s.paths[quiz.ID] if !ok { return "", errors.New(fmt.Sprintf("Path not found for quiz ID %v", quiz.ID)) } return path, nil } func (s *FileProboCollectorStore) SetPath(quiz *models.Quiz, path string) string { s.lock.Lock() defer s.lock.Unlock() s.paths[quiz.ID] = path return path } 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, error) { lines := strings.Split(markdown, "\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" } questionText += line } } questionText = strings.TrimRight(questionText, "\n") if questionText == "" { return nil, fmt.Errorf("Question text should not be empty.") } if len(answers) < 2 { return nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers)) } question := &client.Question{Text: questionText} quiz := &client.Quiz{Question: question, Answers: answers} return quiz, nil } func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error { markdown, err := MarkdownFromQuiz(quiz) if err != nil { return err } fn, _ := s.GetPath(quiz) if fn == "" { fn = filepath.Join(s.Dir, fmt.Sprintf("quiz_%v.%s", time.Now().Unix(), "md")) } file, err := os.Create(fn) if err != nil { return err } defer file.Close() _, err = file.Write([]byte(markdown)) if err != nil { return err } s.SetPath(quiz, fn) return nil }