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 } }