feat: vani of the day edit
This commit is contained in:
@@ -58,6 +58,36 @@ func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type setVaniByURLReq struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
|
||||||
|
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req setVaniByURLReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
day, err := parseVaniDay(req.Day)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pick, err := h.svc.SetByURL(r.Context(), req.URL, day)
|
||||||
|
if err != nil {
|
||||||
|
writeVaniError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"day": pick.Day.Format("2006-01-02"),
|
||||||
|
"kind": pick.Kind,
|
||||||
|
"ref_id": pick.RefID,
|
||||||
|
"payload": pick.Payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
|
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
|
||||||
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
|
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
|
||||||
day, err := parseVaniDay(chi.URLParam(r, "day"))
|
day, err := parseVaniDay(chi.URLParam(r, "day"))
|
||||||
@@ -103,7 +133,9 @@ func parseVaniDay(s string) (time.Time, error) {
|
|||||||
|
|
||||||
func writeVaniError(w http.ResponseWriter, err error) {
|
func writeVaniError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, repository.ErrVaniRefInvalid), errors.Is(err, service.ErrVaniBadKind):
|
case errors.Is(err, repository.ErrVaniRefInvalid),
|
||||||
|
errors.Is(err, service.ErrVaniBadKind),
|
||||||
|
errors.Is(err, service.ErrVaniBadURL):
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, repository.ErrVaniRefNotFound):
|
case errors.Is(err, repository.ErrVaniRefNotFound):
|
||||||
response.Error(w, http.StatusNotFound, err.Error())
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
|||||||
@@ -217,6 +217,63 @@ func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TranscriptBySlug loads a lecture by its URL slug.
|
||||||
|
//
|
||||||
|
// Search cannot do this: the slug is not an indexed field, so querying
|
||||||
|
// "720720sb-london" matches on tokens like "london" and returns whatever else
|
||||||
|
// mentions it. Resolving the slug directly is the only reliable way to pin a
|
||||||
|
// specific item someone already has the URL for.
|
||||||
|
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
|
||||||
|
var t TranscriptionDoc
|
||||||
|
err := r.mongo.Collection("transcriptions").
|
||||||
|
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("transcript by slug: %w", err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProseBySlug loads a prose chapter by book and slug.
|
||||||
|
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
|
||||||
|
var p ProseChapterDoc
|
||||||
|
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
|
||||||
|
{Key: "book", Value: book},
|
||||||
|
{Key: "slug", Value: slug},
|
||||||
|
}).Decode(&p)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prose by slug: %w", err)
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
|
||||||
|
// which is what a verse URL carries.
|
||||||
|
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
|
||||||
|
filter := bson.D{
|
||||||
|
{Key: "book", Value: book},
|
||||||
|
{Key: "chapter", Value: chapter},
|
||||||
|
{Key: "verse", Value: verse},
|
||||||
|
}
|
||||||
|
if division != nil && *division != "" {
|
||||||
|
filter = append(filter, bson.E{Key: "division", Value: *division})
|
||||||
|
}
|
||||||
|
var v VerseDoc
|
||||||
|
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verse by ref: %w", err)
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
|
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
|
||||||
// chosen. The payload is then built by the same builders the random picker
|
// chosen. The payload is then built by the same builders the random picker
|
||||||
// uses, so a pinned pick renders identically to an automatic one.
|
// uses, so a pinned pick renders identically to an automatic one.
|
||||||
|
|||||||
@@ -1164,6 +1164,7 @@ func (s *Server) setupRouter() {
|
|||||||
// Announcements composed and published from the admin panel.
|
// Announcements composed and published from the admin panel.
|
||||||
r.Get("/vani", s.vaniH.Schedule)
|
r.Get("/vani", s.vaniH.Schedule)
|
||||||
r.Post("/vani", s.vaniH.Set)
|
r.Post("/vani", s.vaniH.Set)
|
||||||
|
r.Post("/vani/by-url", s.vaniH.SetByURL)
|
||||||
r.Delete("/vani/{day}", s.vaniH.Clear)
|
r.Delete("/vani/{day}", s.vaniH.Clear)
|
||||||
|
|
||||||
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,8 +34,12 @@ func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService
|
|||||||
// Today returns the current day's pick, choosing (and persisting) one on the
|
// Today returns the current day's pick, choosing (and persisting) one on the
|
||||||
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
||||||
// content to pick.
|
// content to pick.
|
||||||
|
var (
|
||||||
// ErrVaniBadKind is a kind outside verse/prose/transcript.
|
// ErrVaniBadKind is a kind outside verse/prose/transcript.
|
||||||
var ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
|
ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
|
||||||
|
// ErrVaniBadURL is a link that isn't a verse, prose chapter or lecture.
|
||||||
|
ErrVaniBadURL = errors.New("that link isn't a verse, chapter or lecture")
|
||||||
|
)
|
||||||
|
|
||||||
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -79,6 +85,84 @@ func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
|||||||
|
|
||||||
// choose tries the three content kinds in random order and returns the first
|
// choose tries the three content kinds in random order and returns the first
|
||||||
// successful sample, so each day's pick varies across content types.
|
// successful sample, so each day's pick varies across content types.
|
||||||
|
// ResolveURL turns an app URL (or bare path) into a pinnable pick.
|
||||||
|
//
|
||||||
|
// The intended admin workflow: paste the link to the thing you want. Search
|
||||||
|
// cannot serve this — slugs are not indexed, so searching "720720sb-london"
|
||||||
|
// matches on "london" and returns unrelated lectures.
|
||||||
|
//
|
||||||
|
// Accepts an absolute URL or a path, and understands the three shapes the
|
||||||
|
// vāṇī can point at:
|
||||||
|
//
|
||||||
|
// /transcriptions/{slug}
|
||||||
|
// /books/{book}/{chapter}/{verse} (+ /books/{book}/{division}/{c}/{v})
|
||||||
|
// /books/{book}/{slug} (prose chapter)
|
||||||
|
func (s *VaniService) ResolveURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
|
||||||
|
day = day.UTC().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
path := strings.TrimSpace(raw)
|
||||||
|
if u, err := url.Parse(path); err == nil && u.Host != "" {
|
||||||
|
path = u.Path
|
||||||
|
}
|
||||||
|
path = strings.Trim(strings.SplitN(path, "?", 2)[0], "/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, ErrVaniBadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parts[0] {
|
||||||
|
case "transcriptions":
|
||||||
|
t, err := s.repo.TranscriptBySlug(ctx, parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return transcriptPick(t, day), nil
|
||||||
|
|
||||||
|
case "books":
|
||||||
|
book := parts[1]
|
||||||
|
nums := parts[2:]
|
||||||
|
// A trailing division segment (SB cantos, CC līlās) shifts the
|
||||||
|
// chapter/verse pair along by one.
|
||||||
|
var division *string
|
||||||
|
if len(nums) == 3 {
|
||||||
|
d := nums[0]
|
||||||
|
division, nums = &d, nums[1:]
|
||||||
|
}
|
||||||
|
if len(nums) == 2 {
|
||||||
|
chapter, err1 := strconv.Atoi(nums[0])
|
||||||
|
verse, err2 := strconv.Atoi(nums[1])
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
v, err := s.repo.VerseByRef(ctx, book, division, chapter, verse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return versePick(v, day), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not numeric, so it is a prose chapter's bare slug.
|
||||||
|
if len(nums) == 1 {
|
||||||
|
p, err := s.repo.ProseBySlug(ctx, book, nums[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrVaniBadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetByURL resolves a URL and pins the result.
|
||||||
|
func (s *VaniService) SetByURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
|
||||||
|
pick, err := s.ResolveURL(ctx, raw, day)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.repo.Upsert(ctx, pick); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pick, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetForDay pins a specific piece of content as a given day's Vāṇī.
|
// SetForDay pins a specific piece of content as a given day's Vāṇī.
|
||||||
//
|
//
|
||||||
// Takes only a kind and a content id — never a payload. The display blob is
|
// Takes only a kind and a content id — never a payload. The display blob is
|
||||||
|
|||||||
Reference in New Issue
Block a user