Files
ad_fun/chessstars/internal/courses/store.go
T
2026-07-15 13:29:08 +03:00

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
}