package repository import ( "context" "strings" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) type GlossaryRepo struct { col *mongo.Collection } func NewGlossaryRepo(db *MongoDB) *GlossaryRepo { return &GlossaryRepo{col: db.Collection("glossary")} } // GlossaryEntryDoc is the full glossary entry with definition. type GlossaryEntryDoc struct { ID bson.ObjectID `bson:"_id" json:"id"` Term string `bson:"term" json:"term"` Definition string `bson:"definition" json:"definition"` DefinitionHTML string `bson:"definitionHtml" json:"definition_html"` } // GlossaryTermSummary is a lightweight entry for sidebar listing (no definition). type GlossaryTermSummary struct { ID bson.ObjectID `bson:"_id" json:"id"` Term string `bson:"term" json:"term"` } // GlossaryLetterCount holds the count of entries per base letter. type GlossaryLetterCount struct { Letter string `json:"letter"` Count int `json:"count"` } // diacriticToBase maps diacritical first characters to ASCII base letters. // Must match the frontend mapping in the glossary page. var diacriticToBase = map[rune]rune{ 'Ā': 'A', 'Ä': 'A', 'Ī': 'I', 'É': 'I', 'Ū': 'U', 'Ü': 'U', 'Ṛ': 'R', 'Ṝ': 'R', 'Ś': 'S', 'Ç': 'S', 'Ṣ': 'S', 'Ñ': 'N', 'Ṇ': 'N', 'Ṅ': 'N', 'Ṭ': 'T', 'Ḍ': 'D', 'Ḥ': 'H', // Lowercase equivalents 'ā': 'A', 'ä': 'A', 'ī': 'I', 'é': 'I', 'ū': 'U', 'ü': 'U', 'ṛ': 'R', 'ṝ': 'R', 'ś': 'S', 'ç': 'S', 'ṣ': 'S', 'ñ': 'N', 'ṇ': 'N', 'ṅ': 'N', 'ṭ': 'T', 'ḍ': 'D', 'ḥ': 'H', } // baseLetter returns the ASCII base letter for the first character of a term. func baseLetter(term string) string { if term == "" { return "?" } first := []rune(term)[0] if base, ok := diacriticToBase[first]; ok { return string(base) } return strings.ToUpper(string(first)) } // regexPrefixes returns the set of first characters (including diacritical variants) // that map to the given base letter, for use in a MongoDB $regex query. func regexPrefixes(letter string) string { upper := strings.ToUpper(letter) if len(upper) == 0 { return "" } base := rune(upper[0]) // Collect all characters that map to this base letter chars := []rune{base, rune(strings.ToLower(upper)[0])} for k, v := range diacriticToBase { if v == base { chars = append(chars, k) } } // Build regex character class: ^[AĀÄāä] return "^[" + string(chars) + "]" } // --------------------------------------------------------------------------- // Queries // --------------------------------------------------------------------------- // GetAll returns all glossary entries sorted by term. func (r *GlossaryRepo) GetAll(ctx context.Context) ([]GlossaryEntryDoc, error) { opts := options.Find().SetSort(bson.M{"term": 1}) cursor, err := r.col.Find(ctx, bson.M{}, opts) if err != nil { return nil, err } defer cursor.Close(ctx) var results []GlossaryEntryDoc if err := cursor.All(ctx, &results); err != nil { return nil, err } return results, nil } // GetByTerm returns a single glossary entry by exact term match. func (r *GlossaryRepo) GetByTerm(ctx context.Context, term string) (*GlossaryEntryDoc, error) { var doc GlossaryEntryDoc err := r.col.FindOne(ctx, bson.M{"term": term}).Decode(&doc) if err != nil { if err == mongo.ErrNoDocuments { return nil, nil } return nil, err } return &doc, nil } // GetByLetter returns all glossary entries whose term starts with the given // base letter (including diacritical variants). Sorted by term. func (r *GlossaryRepo) GetByLetter(ctx context.Context, letter string) ([]GlossaryEntryDoc, error) { regex := regexPrefixes(letter) if regex == "" { return nil, nil } filter := bson.M{"term": bson.M{"$regex": regex}} opts := options.Find().SetSort(bson.M{"term": 1}) cursor, err := r.col.Find(ctx, filter, opts) if err != nil { return nil, err } defer cursor.Close(ctx) var results []GlossaryEntryDoc if err := cursor.All(ctx, &results); err != nil { return nil, err } return results, nil } // GetTermsByLetter returns lightweight term summaries (no definitions) for a // given base letter. Used for sidebar listing. func (r *GlossaryRepo) GetTermsByLetter(ctx context.Context, letter string) ([]GlossaryTermSummary, error) { regex := regexPrefixes(letter) if regex == "" { return nil, nil } filter := bson.M{"term": bson.M{"$regex": regex}} opts := options.Find(). SetSort(bson.M{"term": 1}). SetProjection(bson.M{"term": 1}) cursor, err := r.col.Find(ctx, filter, opts) if err != nil { return nil, err } defer cursor.Close(ctx) var results []GlossaryTermSummary if err := cursor.All(ctx, &results); err != nil { return nil, err } return results, nil } // GetLetterCounts returns the count of entries per base letter. // This is computed in Go since MongoDB can't easily map diacritical first // characters to base letters in an aggregation pipeline. func (r *GlossaryRepo) GetLetterCounts(ctx context.Context) ([]GlossaryLetterCount, error) { // Fetch only the term field opts := options.Find().SetProjection(bson.M{"term": 1}) cursor, err := r.col.Find(ctx, bson.M{}, opts) if err != nil { return nil, err } defer cursor.Close(ctx) var docs []struct { Term string `bson:"term"` } if err := cursor.All(ctx, &docs); err != nil { return nil, err } counts := make(map[string]int) for _, doc := range docs { letter := baseLetter(doc.Term) counts[letter]++ } // Convert to sorted slice result := make([]GlossaryLetterCount, 0, len(counts)) for letter, count := range counts { result = append(result, GlossaryLetterCount{Letter: letter, Count: count}) } // Sort alphabetically for i := 0; i < len(result); i++ { for j := i + 1; j < len(result); j++ { if result[j].Letter < result[i].Letter { result[i], result[j] = result[j], result[i] } } } return result, nil }