211 lines
5.5 KiB
Go
211 lines
5.5 KiB
Go
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
|
||
}
|
||
}
|