221 lines
5.9 KiB
Go
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
|
|
}
|