376 lines
11 KiB
Go
376 lines
11 KiB
Go
// Package service — VaniService powers "Vāṇī of the Day": one piece of content
|
|
// (a prose chapter, a verse, or a transcript/lecture) surfaced on the home page
|
|
// each day. The pick is chosen once per UTC day and reused for everyone; the
|
|
// picker avoids anything served in the last 90 days.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const vaniNoRepeatDays = 90
|
|
|
|
type VaniService struct {
|
|
repo *repository.VaniRepo
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService {
|
|
return &VaniService{repo: repo, logger: logger}
|
|
}
|
|
|
|
// 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
|
|
// content to pick.
|
|
var (
|
|
// ErrVaniBadKind is a kind outside verse/prose/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) {
|
|
now := time.Now().UTC()
|
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
if existing, err := s.repo.GetForDay(ctx, day); err != nil {
|
|
return nil, err
|
|
} else if existing != nil {
|
|
return &existing.Payload, nil
|
|
}
|
|
|
|
// Build the 90-day exclusion set, bucketed by kind.
|
|
recent, err := s.repo.RecentRefIDs(ctx, day.AddDate(0, 0, -vaniNoRepeatDays))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
exclude := map[string][]string{}
|
|
for _, rid := range recent {
|
|
if kind, hex, ok := splitRef(rid); ok {
|
|
exclude[kind] = append(exclude[kind], hex)
|
|
}
|
|
}
|
|
|
|
pick := s.choose(ctx, exclude, day)
|
|
if pick == nil {
|
|
// Everything in some kind exhausted the 90-day window? Relax it.
|
|
pick = s.choose(ctx, map[string][]string{}, day)
|
|
}
|
|
if pick == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := s.repo.Insert(ctx, pick); err != nil {
|
|
s.logger.Warn("vani: insert failed", zap.Error(err))
|
|
}
|
|
// Re-read so a concurrent writer's row (ON CONFLICT DO NOTHING) wins
|
|
// consistently for everyone.
|
|
if stored, err := s.repo.GetForDay(ctx, day); err == nil && stored != nil {
|
|
return &stored.Payload, nil
|
|
}
|
|
return &pick.Payload, nil
|
|
}
|
|
|
|
// choose tries the three content kinds in random order and returns the first
|
|
// 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āṇī.
|
|
//
|
|
// Takes only a kind and a content id — never a payload. The display blob is
|
|
// rebuilt here with the same builders the random picker uses, so a pinned pick
|
|
// is indistinguishable from an automatic one and a client cannot dictate what
|
|
// the home page says.
|
|
func (s *VaniService) SetForDay(ctx context.Context, day time.Time, kind, hex string) (*model.VaniPick, error) {
|
|
day = day.UTC().Truncate(24 * time.Hour)
|
|
|
|
var pick *model.VaniPick
|
|
switch kind {
|
|
case "verse":
|
|
v, err := s.repo.VerseByHex(ctx, hex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pick = versePick(v, day)
|
|
case "prose":
|
|
p, err := s.repo.ProseByHex(ctx, hex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pick = prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
|
case "transcript":
|
|
t, err := s.repo.TranscriptByHex(ctx, hex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pick = transcriptPick(t, day)
|
|
default:
|
|
return nil, ErrVaniBadKind
|
|
}
|
|
|
|
if err := s.repo.Upsert(ctx, pick); err != nil {
|
|
return nil, err
|
|
}
|
|
return pick, nil
|
|
}
|
|
|
|
// ClearForDay drops a pinned pick, handing the day back to the random picker.
|
|
func (s *VaniService) ClearForDay(ctx context.Context, day time.Time) error {
|
|
return s.repo.Clear(ctx, day.UTC().Truncate(24*time.Hour))
|
|
}
|
|
|
|
// Schedule lists decided days from today onward, for the admin view.
|
|
func (s *VaniService) Schedule(ctx context.Context, limit int) ([]model.VaniPick, error) {
|
|
if limit <= 0 || limit > 60 {
|
|
limit = 30
|
|
}
|
|
return s.repo.PicksFrom(ctx, time.Now().UTC().Truncate(24*time.Hour), limit)
|
|
}
|
|
|
|
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
|
kinds := []string{"verse", "prose", "transcript"}
|
|
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
|
|
|
for _, kind := range kinds {
|
|
switch kind {
|
|
case "verse":
|
|
if v, err := s.repo.SampleVerse(ctx, exclude["verse"]); err == nil && v != nil {
|
|
return versePick(v, day)
|
|
}
|
|
case "prose":
|
|
if p, err := s.repo.SampleProse(ctx, exclude["prose"]); err == nil && p != nil {
|
|
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
|
}
|
|
case "transcript":
|
|
if t, err := s.repo.SampleTranscript(ctx, exclude["transcript"]); err == nil && t != nil {
|
|
return transcriptPick(t, day)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Payload builders ---
|
|
|
|
func versePick(v *repository.VerseDoc, day time.Time) *model.VaniPick {
|
|
title := v.Reference
|
|
if title == "" {
|
|
title = fmt.Sprintf("%s %d.%d", strings.ToUpper(v.Book), v.Chapter, v.Verse)
|
|
}
|
|
return &model.VaniPick{
|
|
Day: day,
|
|
Kind: "verse",
|
|
RefID: "verse:" + v.ID.Hex(),
|
|
Payload: model.VaniPayload{
|
|
Kind: "verse",
|
|
Label: "Verse",
|
|
Title: title,
|
|
Subtitle: excerpt(v.Translation, 140),
|
|
Href: verseURL(v.Book, v.Division, v.Chapter, v.Verse),
|
|
BookTitle: v.BookTitle,
|
|
},
|
|
}
|
|
}
|
|
|
|
func prosePick(p *repository.ProseChapterDoc, bookTitle string, day time.Time) *model.VaniPick {
|
|
first := ""
|
|
if len(p.Paragraphs) > 0 {
|
|
first = p.Paragraphs[0]
|
|
}
|
|
return &model.VaniPick{
|
|
Day: day,
|
|
Kind: "prose",
|
|
RefID: "prose:" + p.ID.Hex(),
|
|
Payload: model.VaniPayload{
|
|
Kind: "prose",
|
|
Label: "Chapter",
|
|
Title: p.Title,
|
|
Subtitle: excerpt(first, 140),
|
|
Href: proseURL(p.Book, p.Volume, p.Type, p.Slug),
|
|
BookTitle: bookTitle,
|
|
},
|
|
}
|
|
}
|
|
|
|
func transcriptPick(t *repository.TranscriptionDoc, day time.Time) *model.VaniPick {
|
|
label := "Lecture"
|
|
if t.Type == "conversation" {
|
|
label = "Conversation"
|
|
}
|
|
date := ""
|
|
if t.Date != nil {
|
|
date = *t.Date
|
|
}
|
|
return &model.VaniPick{
|
|
Day: day,
|
|
Kind: "transcript",
|
|
RefID: "transcript:" + t.ID.Hex(),
|
|
Payload: model.VaniPayload{
|
|
Kind: "transcript",
|
|
Label: label,
|
|
Title: t.Name,
|
|
Href: "/transcriptions/" + t.Slug,
|
|
Date: date,
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- URL builders (mirror cmd/indexer buildVerseURL / prose URL logic) ---
|
|
|
|
func verseURL(book string, division *string, chapter, verse int) string {
|
|
switch book {
|
|
case "bg":
|
|
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
|
|
case "sb":
|
|
if division != nil {
|
|
return fmt.Sprintf("/books/sb/%s/%d/%d", *division, chapter, verse)
|
|
}
|
|
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
|
|
case "cc":
|
|
if division != nil {
|
|
return fmt.Sprintf("/books/cc/%s/%d/%d", *division, chapter, verse)
|
|
}
|
|
return fmt.Sprintf("/books/cc/%d/%d", chapter, verse)
|
|
case "sb1962":
|
|
return fmt.Sprintf("/books/sb1962/%d/%d", chapter, verse)
|
|
case "iso":
|
|
return fmt.Sprintf("/books/iso/%d", chapter)
|
|
case "noi":
|
|
return fmt.Sprintf("/books/noi/%d", chapter)
|
|
case "bs":
|
|
return fmt.Sprintf("/books/bs/5/%d", verse)
|
|
case "mm":
|
|
return fmt.Sprintf("/books/mm/%d/%d", chapter, verse)
|
|
case "nbs":
|
|
return fmt.Sprintf("/books/nbs/%d/%d", chapter, verse)
|
|
default:
|
|
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
|
|
}
|
|
}
|
|
|
|
func proseURL(book string, volume int, docType, slug string) string {
|
|
switch {
|
|
case book == "kb":
|
|
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
|
|
case docType == "chapter-summary":
|
|
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
|
|
return fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
|
|
}
|
|
return fmt.Sprintf("/books/%s/%s", book, slug)
|
|
default:
|
|
return fmt.Sprintf("/books/%s/%s", book, slug)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func splitRef(ref string) (kind, hex string, ok bool) {
|
|
if i := strings.IndexByte(ref, ':'); i > 0 {
|
|
return ref[:i], ref[i+1:], true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
|
var wsRe = regexp.MustCompile(`\s+`)
|
|
|
|
// excerpt strips HTML, collapses whitespace, and truncates to n runes with an
|
|
// ellipsis — a clean one-line teaser for the CTA.
|
|
func excerpt(html string, n int) string {
|
|
s := htmlTagRe.ReplaceAllString(html, "")
|
|
s = strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return strings.TrimSpace(string(r[:n])) + "…"
|
|
}
|