feat: add books APIs
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user