Files
spsays-bff/internal/repository/sitemap_repo.go

211 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"context"
"fmt"
"sort"
"strings"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// SitemapRepo enumerates every public content leaf path for the sitemap.
// All queries are projection-only (no content hydration), so a single sweep
// over the ~tens-of-thousands of content docs stays cheap. URL construction
// mirrors cmd/indexer (the canonical URLs already indexed into Typesense), so
// sitemap entries match the real, crawlable pages exactly.
type SitemapRepo struct{ db *MongoDB }
func NewSitemapRepo(db *MongoDB) *SitemapRepo { return &SitemapRepo{db: db} }
// ContentURLs returns the sorted, de-duplicated set of content leaf paths
// (relative — the caller prefixes the origin).
func (r *SitemapRepo) ContentURLs(ctx context.Context) ([]string, error) {
set := make(map[string]struct{}, 40000)
add := func(u string) {
if u != "" {
set[u] = struct{}{}
}
}
// Verses (bg/sb/cc/sb1962/iso/noi/bs/mm/nbs/…).
if err := r.each(ctx, "verses",
bson.M{"book": 1, "division": 1, "chapter": 1, "verse": 1},
func(m bson.M) {
add(verseURL(asString(m["book"]), asString(m["division"]),
asInt(m["chapter"]), asInt(m["verse"])))
}); err != nil {
return nil, err
}
// Prose chapters (KB volumes, CC chapter summaries, NoD/BBD/… front matter).
if err := r.each(ctx, "prose_chapters",
bson.M{"book": 1, "volume": 1, "slug": 1, "type": 1},
func(m bson.M) {
add(proseURL(asString(m["book"]), asInt(m["volume"]),
asString(m["slug"]), asString(m["type"])))
}); err != nil {
return nil, err
}
// Slug-keyed collections.
for _, c := range []struct{ coll, prefix string }{
{"letters", "/letters/"},
{"transcriptions", "/transcriptions/"},
{"essays", "/essays/"},
} {
if err := r.each(ctx, c.coll, bson.M{"slug": 1}, func(m bson.M) {
if s := asString(m["slug"]); s != "" {
add(c.prefix + s)
}
}); err != nil {
return nil, err
}
}
// Book-scoped chapter collections (book code is the URL slug, per indexer).
for _, c := range []struct{ coll, prefix string }{
{"journal_chapters", "/journals/"},
{"magazine_issues", "/magazines/"},
{"other_document_chapters", "/other-documents/"},
} {
if err := r.each(ctx, c.coll, bson.M{"book": 1, "slug": 1}, func(m bson.M) {
b, s := asString(m["book"]), asString(m["slug"])
if b != "" && s != "" {
add(fmt.Sprintf("%s%s/%s", c.prefix, b, s))
}
}); err != nil {
return nil, err
}
}
// Glossary letter pages — one per distinct first letter that has entries.
letters := make(map[string]struct{})
if err := r.each(ctx, "glossary", bson.M{"term": 1}, func(m bson.M) {
if l := firstAlphaLower(asString(m["term"])); l != "" {
letters[l] = struct{}{}
}
}); err != nil {
return nil, err
}
for l := range letters {
add("/glossary/" + l)
}
out := make([]string, 0, len(set))
for u := range set {
out = append(out, u)
}
sort.Strings(out)
return out, nil
}
// each streams a collection with the given projection, invoking fn per doc.
func (r *SitemapRepo) each(ctx context.Context, coll string, projection bson.M, fn func(bson.M)) error {
cur, err := r.db.Collection(coll).Find(ctx, bson.M{}, options.Find().SetProjection(projection))
if err != nil {
return fmt.Errorf("sitemap query %s: %w", coll, err)
}
defer cur.Close(ctx)
for cur.Next(ctx) {
var m bson.M
if err := cur.Decode(&m); err != nil {
continue
}
fn(m)
}
return cur.Err()
}
// --- URL builders (mirror cmd/indexer/main.go) ---
func verseURL(book, division string, chapter, verse int) string {
switch book {
case "bg":
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
case "sb":
if division != "" {
return fmt.Sprintf("/books/sb/%s/%d/%d", division, chapter, verse)
}
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
case "cc":
if division != "" {
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)
case "":
return ""
default:
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
}
}
func proseURL(book string, volume int, slug, typ string) string {
if slug == "" {
return ""
}
switch {
case book == "kb":
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
case typ == "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)
}
}
// firstAlphaLower returns the lowercased first ASCII letter of a term, or ""
// when it doesn't start with a-z (matches the a–z glossary letter routes).
func firstAlphaLower(term string) string {
term = strings.TrimSpace(term)
if term == "" {
return ""
}
c := term[0]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
if c >= 'a' && c <= 'z' {
return string(c)
}
return ""
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func asInt(v any) int {
switch n := v.(type) {
case int32:
return int(n)
case int64:
return int(n)
case int:
return n
case float64:
return int(n)
default:
return 0
}
}