284 lines
6.7 KiB
Go
284 lines
6.7 KiB
Go
package courses
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("courses: not found")
|
|
|
|
type MoveRef struct {
|
|
From uint8 `json:"from"`
|
|
To uint8 `json:"to"`
|
|
Promo uint8 `json:"promo"`
|
|
}
|
|
|
|
type Course struct {
|
|
ID int64
|
|
AuthorID int64
|
|
AuthorName string
|
|
Title string
|
|
Description string
|
|
Visibility string // "public" or "private"
|
|
Variant int
|
|
StartFEN string
|
|
RuleFlags uint32
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type Chapter struct {
|
|
ID int64
|
|
CourseID int64
|
|
Ordinal int
|
|
Title string
|
|
Annotation string
|
|
Moves []MoveRef
|
|
SolutionNote string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type courseMeta struct {
|
|
ID int64 `json:"id"`
|
|
AuthorID int64 `json:"author_id"`
|
|
AuthorName string `json:"author_name"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Visibility string `json:"visibility"`
|
|
Variant int `json:"variant"`
|
|
StartFEN string `json:"start_fen"`
|
|
RuleFlags uint32 `json:"rule_flags"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type chapterFile struct {
|
|
Ordinal int `json:"ordinal"`
|
|
Title string `json:"title"`
|
|
Annotation string `json:"annotation"`
|
|
Moves []MoveRef `json:"moves"`
|
|
SolutionNote string `json:"solution_note"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
dir string
|
|
}
|
|
|
|
func Open(dir string) (*Store, error) {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Store{dir: dir}, nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return nil }
|
|
|
|
func (s *Store) metaPath(id int64) string {
|
|
return filepath.Join(s.dir, fmt.Sprintf("%d.meta.txt", id))
|
|
}
|
|
|
|
func (s *Store) chapterPath(courseID, chapterID int64) string {
|
|
return filepath.Join(s.dir, fmt.Sprintf("%d.chapter.%d.txt", courseID, chapterID))
|
|
}
|
|
|
|
func writeJSON(path string, v any) error {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, b, 0o644)
|
|
}
|
|
|
|
func readJSON(path string, v any) error {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(b, v)
|
|
}
|
|
|
|
func (s *Store) nextCourseID() (int64, error) {
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var maxID int64
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasSuffix(name, ".meta.txt") {
|
|
continue
|
|
}
|
|
idStr := strings.TrimSuffix(name, ".meta.txt")
|
|
n, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err == nil && n > maxID {
|
|
maxID = n
|
|
}
|
|
}
|
|
return maxID + 1, nil
|
|
}
|
|
|
|
func (s *Store) nextChapterID(courseID int64) (int64, error) {
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
prefix := fmt.Sprintf("%d.chapter.", courseID)
|
|
var maxID int64
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".txt") {
|
|
continue
|
|
}
|
|
idStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".txt")
|
|
n, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err == nil && n > maxID {
|
|
maxID = n
|
|
}
|
|
}
|
|
return maxID + 1, nil
|
|
}
|
|
|
|
func (s *Store) CreateCourse(c Course) (*Course, error) {
|
|
if c.Visibility != "public" && c.Visibility != "private" {
|
|
c.Visibility = "private"
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
id, err := s.nextCourseID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta := courseMeta{
|
|
ID: id, AuthorID: c.AuthorID, AuthorName: c.AuthorName, Title: c.Title,
|
|
Description: c.Description, Visibility: c.Visibility, Variant: c.Variant,
|
|
StartFEN: c.StartFEN, RuleFlags: c.RuleFlags, CreatedAt: time.Now(),
|
|
}
|
|
if err := writeJSON(s.metaPath(id), meta); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetCourse(id)
|
|
}
|
|
|
|
func courseFromMeta(m courseMeta) *Course {
|
|
return &Course{
|
|
ID: m.ID, AuthorID: m.AuthorID, AuthorName: m.AuthorName, Title: m.Title,
|
|
Description: m.Description, Visibility: m.Visibility, Variant: m.Variant,
|
|
StartFEN: m.StartFEN, RuleFlags: m.RuleFlags, CreatedAt: m.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Store) GetCourse(id int64) (*Course, error) {
|
|
var meta courseMeta
|
|
if err := readJSON(s.metaPath(id), &meta); err != nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
return courseFromMeta(meta), nil
|
|
}
|
|
|
|
func (s *Store) listCourses(filter func(*Course) bool) ([]Course, error) {
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Course
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".meta.txt") {
|
|
continue
|
|
}
|
|
idStr := strings.TrimSuffix(name, ".meta.txt")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
c, err := s.GetCourse(id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if filter(c) {
|
|
out = append(out, *c)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) ListPublicCourses() ([]Course, error) {
|
|
return s.listCourses(func(c *Course) bool { return c.Visibility == "public" })
|
|
}
|
|
|
|
func (s *Store) ListCoursesByAuthor(authorID int64) ([]Course, error) {
|
|
return s.listCourses(func(c *Course) bool { return c.AuthorID == authorID })
|
|
}
|
|
|
|
func (s *Store) AddChapter(ch Chapter) (*Chapter, error) {
|
|
if _, err := s.GetCourse(ch.CourseID); err != nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
id, err := s.nextChapterID(ch.CourseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cf := chapterFile{
|
|
Ordinal: ch.Ordinal, Title: ch.Title, Annotation: ch.Annotation,
|
|
Moves: ch.Moves, SolutionNote: ch.SolutionNote, CreatedAt: time.Now(),
|
|
}
|
|
if err := writeJSON(s.chapterPath(ch.CourseID, id), cf); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetChapter(ch.CourseID, id)
|
|
}
|
|
|
|
func (s *Store) GetChapter(courseID, id int64) (*Chapter, error) {
|
|
var cf chapterFile
|
|
if err := readJSON(s.chapterPath(courseID, id), &cf); err != nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &Chapter{
|
|
ID: id, CourseID: courseID, Ordinal: cf.Ordinal, Title: cf.Title,
|
|
Annotation: cf.Annotation, Moves: cf.Moves, SolutionNote: cf.SolutionNote,
|
|
CreatedAt: cf.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) ListChaptersByCourse(courseID int64) ([]Chapter, error) {
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefix := fmt.Sprintf("%d.chapter.", courseID)
|
|
var out []Chapter
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".txt") {
|
|
continue
|
|
}
|
|
idStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".txt")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ch, err := s.GetChapter(courseID, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, *ch)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Ordinal < out[j].Ordinal })
|
|
return out, nil
|
|
}
|