Files
spsays-bff/internal/repository/glossary_repo.go
T
2026-05-09 23:19:19 +05:30

221 lines
5.9 KiB
Go

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
}