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 }