// 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])) + "…" }