English/Japanese dictionary

6 tier search query and other minimal changes

+334 -83
+10
.envrc
··· 1 + use flake 2 + 3 + export HOST_IP="127.0.0.1" 4 + export SERVE_PORT="8080" 5 + export MONGO_PORT="27017" 6 + export MONGO_DATABASE_PATH="./data/mongodb" 7 + export BLEVE_INDEX_PATH="./data/bleve_index" 8 + export BLEVE_EXPLAIN_ENABLE="true" 9 + export DEVELOPMENT_TOKEN="local-dev-token" 10 + export CGO_ENABLED="1"
+3
.gitignore
··· 10 10 JMdict 11 11 JMdict_e 12 12 13 + # Direnv 14 + .direnv 15 + 13 16 # Nix 14 17 .pre-commit-config.yaml 15 18 result
+2 -2
docs/postman/collections/public_version1.postman_collection.json
··· 12 12 "method": "GET", 13 13 "header": [], 14 14 "url": { 15 - "raw": "{{PROTOCOL}}://{{HOSTNAME}}:{{PORT}}/{{VERSION}}/jisho/search?q=cat", 15 + "raw": "{{PROTOCOL}}://{{HOSTNAME}}:{{PORT}}/{{VERSION}}/jisho/search?q=dog", 16 16 "protocol": "{{PROTOCOL}}", 17 17 "host": [ 18 18 "{{HOSTNAME}}" ··· 26 26 "query": [ 27 27 { 28 28 "key": "q", 29 - "value": "cat", 29 + "value": "dog", 30 30 "uuid": "13e9e2d3-8961-4541-9b1c-928ca24ca60f" 31 31 } 32 32 ]
-13
flake.nix
··· 62 62 go install github.com/blevesearch/bleve/v2/cmd/bleve@latest 63 63 fi 64 64 65 - # Add nix-shell indicator to prompt 66 - export PS1="(☞゚ヮ゚)☞ \u@\h:\W$ " 67 - 68 65 echo "Setup ready!" 69 66 ''; 70 - 71 - # Environment variables 72 - HOST_IP = "127.0.0.1"; 73 - SERVE_PORT = "8080"; 74 - MONGO_PORT = "27017"; 75 - MONGO_DATABASE_PATH = "./data/mongodb"; 76 - BLEVE_INDEX_PATH = "./data/bleve_index"; 77 - BLEVE_EXPLAIN_ENABLE = "true"; 78 - DEVELOPMENT_TOKEN = "local-dev-token"; 79 - CGO_ENABLED = "1"; 80 67 }; 81 68 }); 82 69 };
+3 -3
internal/adapters/primary/http/templates/partials/search_results.html
··· 32 32 <div class="pos">{{range $sense.PartsOfSpeech}}{{.}} {{end}}</div> 33 33 {{end}} 34 34 35 - <ul class="glosses"> 35 + <div class="glosses"> 36 36 {{range $sense.Glosses}} 37 - <li>{{.}}</li> 37 + <span class="gloss">{{.}}</span> 38 38 {{end}} 39 - </ul> 39 + </div> 40 40 </div> 41 41 </div> 42 42 {{end}}
+4 -5
internal/adapters/secondary/persistence/mongodb.go
··· 24 24 collection *mongo.Collection 25 25 } 26 26 27 - // TODO: Code related with MongoDB and Entry collection should be separated at some point 28 27 func NewMongoEntryRepository(connectionString, databaseName, collectionName string) (ports.EntryRepository, error) { 29 28 clientOptions := options.Client().ApplyURI(connectionString) 30 29 client, err := mongo.Connect(context.Background(), clientOptions) ··· 115 114 return score 116 115 } 117 116 118 - func (r *MongoEntryRepository) GetByIDs(ctx context.Context, entryIDs []string) ([]*domain.Entry, error) { 119 - if len(entryIDs) == 0 { 117 + func (r *MongoEntryRepository) GetByIDs(ctx context.Context, entryIds []string) ([]*domain.Entry, error) { 118 + if len(entryIds) == 0 { 120 119 return []*domain.Entry{}, nil 121 120 } 122 121 123 - filter := bson.M{"id": bson.M{"$in": entryIDs}} 122 + filter := bson.M{"id": bson.M{"$in": entryIds}} 124 123 cursor, err := r.collection.Find(ctx, filter) 125 124 if err != nil { 126 125 return nil, err ··· 142 141 } 143 142 } 144 143 145 - return entries, nil 144 + return SortEntriesByIds(entries, entryIds), nil 146 145 } 147 146 148 147 func (r *MongoEntryRepository) Close() error {
+38
internal/adapters/secondary/persistence/sort.go
··· 1 + package persistence 2 + 3 + import ( 4 + "dictionary-api/internal/core/domain" 5 + "sort" 6 + ) 7 + 8 + func buildSortOrder(ids []string) map[string]int { 9 + dict := make(map[string]int) 10 + 11 + for i, id := range ids { 12 + dict[id] = i 13 + } 14 + 15 + return dict 16 + } 17 + 18 + func MatchSorting(entries []*domain.Entry, sortedIds []string) { 19 + sortOrder := buildSortOrder(sortedIds) 20 + 21 + sort.Slice(entries, func(i, j int) bool { 22 + iId, jId := entries[i].SequenceNumber, entries[j].SequenceNumber 23 + return sortOrder[iId] < sortOrder[jId] 24 + }) 25 + } 26 + 27 + func SortEntriesByIds(entries []*domain.Entry, sortedIds []string) []*domain.Entry { 28 + sortOrder := buildSortOrder(sortedIds) 29 + sorted := make([]*domain.Entry, len(entries)) 30 + copy(sorted, entries) 31 + 32 + sort.Slice(sorted, func(i, j int) bool { 33 + iId, jId := sorted[i].SequenceNumber, sorted[j].SequenceNumber 34 + return sortOrder[iId] < sortOrder[jId] 35 + }) 36 + 37 + return sorted 38 + }
+173 -36
internal/adapters/secondary/search/bleve.go
··· 3 3 import ( 4 4 "dictionary-api/internal/core/domain" 5 5 "dictionary-api/internal/core/ports" 6 + "fmt" 6 7 "os" 8 + "strconv" 7 9 "strings" 8 10 9 11 "github.com/blevesearch/bleve/v2" 12 + // Import 'simple' to use it as analyzer in the meanings field. Removing this import breaks the build. 13 + _ "github.com/blevesearch/bleve/v2/analysis/analyzer/simple" 10 14 "github.com/blevesearch/bleve/v2/analysis/lang/cjk" 11 15 "github.com/blevesearch/bleve/v2/mapping" 12 16 "github.com/blevesearch/bleve/v2/search" 17 + "github.com/blevesearch/bleve/v2/search/query" 13 18 ) 14 19 15 20 type TextSearchEntry struct { 16 - ID string `json:"id"` 17 - SortIndex int `json:"sortIndex"` 18 - Kanji string `json:"kanji,omitempty"` 19 - Reading string `json:"reading,omitempty"` 20 - Meaning string `json:"meaning,omitempty"` 21 - Priority string `json:"priority,omitempty"` 21 + ID string `json:"id"` 22 + SortIndex string `json:"sortIndex"` 23 + Priority float64 `json:"priority"` 24 + Kanji string `json:"kanji,omitempty"` 25 + Reading string `json:"reading,omitempty"` 26 + Meaning string `json:"meaning,omitempty"` 27 + CanonicalMeaning string `json:"canonicalMeaning"` 22 28 } 23 29 24 30 type BleveSearchRepository struct { 25 - index bleve.Index 31 + index bleve.Index 32 + enableSearchLogs bool 33 + logger ports.LoggerInterface 26 34 } 27 35 28 - // TODO: Code related with Bleve and Search collection should be separated at some point 29 - func NewBleveSearchRepository(indexPath string) (ports.SearchRepository, error) { 36 + func NewBleveSearchRepository(indexPath string, explainEnabled bool, logger ports.LoggerInterface) (ports.SearchRepository, error) { 30 37 var index bleve.Index 31 38 var err error 32 39 ··· 45 52 } 46 53 47 54 return &BleveSearchRepository{ 48 - index: index, 55 + index: index, 56 + enableSearchLogs: explainEnabled, 57 + logger: logger, 49 58 }, nil 50 59 } 51 60 61 + // This thing (Japanese/English only) weights 322MB already 52 62 func createIndexMapping() mapping.IndexMapping { 53 63 indexMapping := bleve.NewIndexMapping() 54 64 searchDocMapping := bleve.NewDocumentMapping() ··· 60 70 idFieldMapping.Store = true 61 71 searchDocMapping.AddFieldMappingsAt("id", idFieldMapping) 62 72 73 + sortIndexFieldMapping := bleve.NewKeywordFieldMapping() 74 + sortIndexFieldMapping.Index = true 75 + sortIndexFieldMapping.Store = false 76 + searchDocMapping.AddFieldMappingsAt("sortIndex", sortIndexFieldMapping) 77 + 78 + // Numeric field for priority-based boosting 79 + priorityFieldMapping := bleve.NewNumericFieldMapping() 80 + priorityFieldMapping.Index = true 81 + priorityFieldMapping.Store = false 82 + searchDocMapping.AddFieldMappingsAt("priority", priorityFieldMapping) 83 + 63 84 kanjiFieldMapping := bleve.NewTextFieldMapping() 64 85 kanjiFieldMapping.Analyzer = cjk.AnalyzerName 65 86 kanjiFieldMapping.Store = false ··· 71 92 searchDocMapping.AddFieldMappingsAt("reading", readingFieldMapping) 72 93 73 94 meaningFieldMapping := bleve.NewTextFieldMapping() 74 - meaningFieldMapping.IncludeTermVectors = true 95 + meaningFieldMapping.Analyzer = "simple" 75 96 meaningFieldMapping.Store = false 76 97 searchDocMapping.AddFieldMappingsAt("meaning", meaningFieldMapping) 77 98 78 - priorityFieldMapping := bleve.NewTextFieldMapping() 79 - priorityFieldMapping.Store = true 80 - priorityFieldMapping.Index = true 81 - // Avoid results with a search term matching priority tags like "ichi1" or "news2" 82 - priorityFieldMapping.IncludeInAll = false 83 - searchDocMapping.AddFieldMappingsAt("priority", priorityFieldMapping) 99 + // Keyword field for exact canonical meaning match (stored lowercased, not tokenised) 100 + canonicalMeaningFieldMapping := bleve.NewKeywordFieldMapping() 101 + canonicalMeaningFieldMapping.Index = true 102 + canonicalMeaningFieldMapping.Store = false 103 + searchDocMapping.AddFieldMappingsAt("canonicalMeaning", canonicalMeaningFieldMapping) 84 104 85 105 indexMapping.AddDocumentMapping("search", searchDocMapping) 86 106 indexMapping.DefaultMapping = searchDocMapping 107 + indexMapping.ScoringModel = "bm25" 87 108 88 109 return indexMapping 89 110 } 90 111 112 + func (r *BleveSearchRepository) logSearchResults(hits search.DocumentMatchCollection) { 113 + for i, hit := range hits { 114 + id := "-" 115 + if val, ok := hit.Fields["id"]; ok { 116 + id = fmt.Sprintf("%v", val) 117 + } 118 + 119 + if hit.Expl != nil { 120 + r.logger.Debug("[%d] docID=%s id=%s score=%.4f\n%s", i, hit.ID, id, hit.Score, hit.Expl.String()) 121 + } else { 122 + r.logger.Debug("[%d] docID=%s id=%s score=%.4f", i, hit.ID, id, hit.Score) 123 + } 124 + } 125 + } 126 + 127 + // Returns the text before the first '(' or ';', lowercased and trimmed. 128 + // Examples: 129 + // - Works great on cases like "dog (Canis lupus familiaris)" → "dog" 130 + // - But, "guide dog" → "guide dog" 131 + func canonicalMeaning(gloss string) string { 132 + if i := strings.IndexAny(gloss, "(;"); i != -1 { 133 + gloss = gloss[:i] 134 + } 135 + return strings.ToLower(strings.TrimSpace(gloss)) 136 + } 137 + 138 + func priorityScore(entry *domain.Entry) float64 { 139 + max := 0 140 + 141 + for _, k := range entry.KanjiElements { 142 + sum := 0 143 + for _, tag := range k.Priority { 144 + if w, ok := domain.GetPriorityWeight(tag); ok { 145 + sum += w 146 + } 147 + } 148 + if sum > max { 149 + max = sum 150 + } 151 + } 152 + 153 + for _, r := range entry.ReadingElements { 154 + sum := 0 155 + for _, tag := range r.Priority { 156 + if w, ok := domain.GetPriorityWeight(tag); ok { 157 + sum += w 158 + } 159 + } 160 + if sum > max { 161 + max = sum 162 + } 163 + } 164 + 165 + return float64(max) 166 + } 167 + 91 168 func (r *BleveSearchRepository) IndexBatch(entries []*domain.Entry) error { 92 169 if len(entries) == 0 { 93 170 return nil ··· 101 178 if kanji.Text != "" { 102 179 doc := TextSearchEntry{ 103 180 ID: entry.SequenceNumber, 104 - SortIndex: i, 181 + SortIndex: strconv.Itoa(i), 105 182 Kanji: kanji.Text, 106 - Priority: strings.Join(kanji.Priority, " "), 107 183 } 108 184 searchID := domain.CreateSearchID(entry.SequenceNumber, domain.SearchIDTypeKanji, i, -1) 109 185 documents.Index(searchID, doc) ··· 114 190 if reading.Text != "" { 115 191 doc := TextSearchEntry{ 116 192 ID: entry.SequenceNumber, 117 - SortIndex: i, 193 + SortIndex: strconv.Itoa(i), 118 194 Reading: reading.Text, 119 - Priority: strings.Join(reading.Priority, " "), 120 195 } 121 196 searchID := domain.CreateSearchID(entry.SequenceNumber, domain.SearchIDTypeReading, i, -1) 122 197 documents.Index(searchID, doc) ··· 127 202 for j, gloss := range sense.Glosses { 128 203 if gloss != "" { 129 204 doc := TextSearchEntry{ 130 - ID: entry.SequenceNumber, 131 - SortIndex: i, // The sorting preference is focused on the sense order only 132 - Meaning: gloss, 205 + ID: entry.SequenceNumber, 206 + SortIndex: strconv.Itoa(i + j), // (0 is the first gloss in the first sense) 207 + Priority: priorityScore(entry), 208 + Meaning: gloss, 209 + CanonicalMeaning: canonicalMeaning(gloss), 133 210 } 134 211 searchID := domain.CreateSearchID(entry.SequenceNumber, domain.SearchIDTypeMeaning, i, j) 135 212 documents.Index(searchID, doc) ··· 143 220 } 144 221 145 222 func (r *BleveSearchRepository) Search(term string, limit int) ([]string, error) { 146 - termQuery := bleve.NewMatchQuery(term) 223 + lowerTerm := strings.ToLower(term) 147 224 148 - searchRequest := bleve.NewSearchRequest(termQuery) 225 + newSenseZeroQuery := func() query.Query { 226 + q := bleve.NewTermQuery("0") 227 + q.SetField("sortIndex") 228 + return q 229 + } 230 + 231 + newCanonicalQuery := func() query.Query { 232 + q := bleve.NewTermQuery(lowerTerm) 233 + q.SetField("canonicalMeaning") 234 + return q 235 + } 236 + 237 + newMatchMeaningQuery := func() query.Query { 238 + q := bleve.NewMatchQuery(term) 239 + q.SetField("meaning") 240 + return q 241 + } 242 + 243 + newPriorityRangeQuery := func(minPriority float64) query.Query { 244 + min := minPriority 245 + q := bleve.NewNumericRangeQuery(&min, nil) 246 + q.SetField("priority") 247 + return q 248 + } 249 + 250 + // The meanings query is splitted in 6 tiers: 251 + // 1. Exact canonical match + primary sense + high priority (≥90) (boost=100) 252 + // 2. Exact canonical match + primary sense (boost=40) 253 + // 3. BM25 meaning match + primary sense + high priority (≥90) (boost=15) 254 + // 4. BM25 meaning match + medium priority (≥50) (boost=5) 255 + // 5. BM25 meaning match + primary sense (boost=2) 256 + // 6. Base BM25 meaning match (boost=1) 257 + 258 + tier1 := bleve.NewConjunctionQuery(newCanonicalQuery(), newSenseZeroQuery(), newPriorityRangeQuery(90.0)) 259 + tier1.SetBoost(100.0) 260 + 261 + tier2 := bleve.NewConjunctionQuery(newCanonicalQuery(), newSenseZeroQuery()) 262 + tier2.SetBoost(40.0) 263 + 264 + tier3 := bleve.NewConjunctionQuery(newMatchMeaningQuery(), newSenseZeroQuery(), newPriorityRangeQuery(90.0)) 265 + tier3.SetBoost(15.0) 266 + 267 + tier4 := bleve.NewConjunctionQuery(newMatchMeaningQuery(), newPriorityRangeQuery(50.0)) 268 + tier4.SetBoost(5.0) 269 + 270 + tier5 := bleve.NewConjunctionQuery(newMatchMeaningQuery(), newSenseZeroQuery()) 271 + tier5.SetBoost(2.0) 272 + 273 + tier6 := bleve.NewMatchQuery(term) 274 + tier6.SetField("meaning") 275 + tier6.SetBoost(1.0) 276 + 277 + // Also include kanji and reading queries for CJK searches 278 + kanjiQuery := bleve.NewMatchQuery(term) 279 + kanjiQuery.SetField("kanji") 280 + 281 + readingQuery := bleve.NewMatchQuery(term) 282 + readingQuery.SetField("reading") 283 + 284 + disjunctionQuery := bleve.NewDisjunctionQuery( 285 + tier1, tier2, tier3, tier4, tier5, tier6, // English search 286 + kanjiQuery, readingQuery, // Japanese search 287 + ) 288 + 289 + searchRequest := bleve.NewSearchRequest(disjunctionQuery) 149 290 searchRequest.Size = limit 150 291 searchRequest.Fields = []string{"id"} 151 - searchRequest.SortByCustom(search.SortOrder{ 152 - &search.SortField{ 153 - Field: "SortIndex", 154 - Desc: false, // ascending: closer to 0 = higher priority 155 - Type: search.SortFieldAsNumber, // ensure numeric sort 156 - }, 157 - &search.SortScore{Desc: true}, // then by score descending 158 - &search.SortDocID{}, // tie-breaker for total order 159 - }) 160 292 161 293 searchResult, err := r.index.Search(searchRequest) 162 294 if err != nil { 163 295 return nil, err 164 296 } 165 297 166 - ids := Hits(searchResult.Hits).Deduplicate(limit) 298 + if r.enableSearchLogs { 299 + r.logSearchResults(searchResult.Hits) 300 + } 301 + 302 + ids := DeduplicateHits(searchResult.Hits) 303 + 167 304 return ids, nil 168 305 } 169 306
+92 -3
internal/adapters/secondary/search/bleve_test.go
··· 20 20 indexPath := filepath.Join(tmpDir, "test_index") 21 21 22 22 // Create a new repository 23 - repo, err := NewBleveSearchRepository(indexPath) 23 + repo, err := NewBleveSearchRepository(indexPath, false, nil) 24 24 if err != nil { 25 25 t.Fatalf("Failed to create repository: %v", err) 26 26 } ··· 54 54 { 55 55 SequenceNumber: "2345678", 56 56 KanjiElements: []domain.KanjiElement{ 57 - {Text: "犬"}, 57 + {Text: "犬", Priority: []string{"news1", "ichi1"}}, 58 58 }, 59 59 ReadingElements: []domain.ReadingElement{ 60 - {Text: "いぬ"}, 60 + {Text: "いぬ", Priority: []string{"news1", "ichi1"}}, 61 61 }, 62 62 Senses: []domain.Sense{ 63 63 {Glosses: []string{"dog"}}, ··· 265 265 t.Errorf("Expected ID '9999999', got '%s'", results[0]) 266 266 } 267 267 } 268 + 269 + func TestBleveSearchRepository_PriorityRanking(t *testing.T) { 270 + repo, cleanup := setupTestScenario(t) 271 + defer cleanup() 272 + 273 + // 犬 (ichi1) should rank above compound "guide dog" entries with no priority tags 274 + entries := []*domain.Entry{ 275 + { 276 + SequenceNumber: "1068700", 277 + KanjiElements: []domain.KanjiElement{ 278 + {Text: "犬", Priority: []string{"news1", "ichi1"}}, 279 + }, 280 + ReadingElements: []domain.ReadingElement{ 281 + {Text: "いぬ", Priority: []string{"news1", "ichi1"}}, 282 + }, 283 + Senses: []domain.Sense{ 284 + {Glosses: []string{"dog"}}, 285 + }, 286 + }, 287 + { 288 + SequenceNumber: "5000001", 289 + KanjiElements: []domain.KanjiElement{ 290 + {Text: "盲導犬"}, 291 + }, 292 + ReadingElements: []domain.ReadingElement{ 293 + {Text: "もうどうけん"}, 294 + }, 295 + Senses: []domain.Sense{ 296 + {Glosses: []string{"guide dog"}}, 297 + }, 298 + }, 299 + { 300 + SequenceNumber: "5000002", 301 + KanjiElements: []domain.KanjiElement{ 302 + {Text: "警察犬"}, 303 + }, 304 + ReadingElements: []domain.ReadingElement{ 305 + {Text: "けいさつけん"}, 306 + }, 307 + Senses: []domain.Sense{ 308 + {Glosses: []string{"police dog"}}, 309 + }, 310 + }, 311 + { 312 + SequenceNumber: "5000003", 313 + KanjiElements: []domain.KanjiElement{ 314 + {Text: "首輪"}, 315 + }, 316 + ReadingElements: []domain.ReadingElement{ 317 + {Text: "くびわ"}, 318 + }, 319 + Senses: []domain.Sense{ 320 + {Glosses: []string{"dog collar", "collar"}}, 321 + }, 322 + }, 323 + } 324 + 325 + err := repo.IndexBatch(entries) 326 + if err != nil { 327 + t.Fatalf("Failed to index entries: %v", err) 328 + } 329 + 330 + results, err := repo.Search("dog", 10) 331 + if err != nil { 332 + t.Fatalf("Search failed: %v", err) 333 + } 334 + 335 + if len(results) == 0 { 336 + t.Fatal("Expected results, got none") 337 + } 338 + 339 + if results[0] != "1068700" { 340 + t.Errorf("Expected 犬 (1068700) to be first result, got %s (full results: %v)", results[0], results) 341 + } 342 + 343 + // Searching "guide dog" should put the guide dog entry first 344 + guideDogResults, err := repo.Search("guide dog", 10) 345 + if err != nil { 346 + t.Fatalf("Search failed: %v", err) 347 + } 348 + 349 + if len(guideDogResults) == 0 { 350 + t.Fatal("Expected results for 'guide dog', got none") 351 + } 352 + 353 + if guideDogResults[0] != "5000001" { 354 + t.Errorf("Expected guide dog entry (5000001) to be first for 'guide dog', got %s", guideDogResults[0]) 355 + } 356 + }
+2 -8
internal/adapters/secondary/search/hits.go internal/adapters/secondary/search/deduplicate.go
··· 2 2 3 3 import "github.com/blevesearch/bleve/v2/search" 4 4 5 - type Hits []*search.DocumentMatch 6 - 7 - func (h Hits) Deduplicate(limit int) []string { 5 + func DeduplicateHits(h []*search.DocumentMatch) []string { 8 6 seen := make(map[string]bool) 9 - ids := make([]string, 0, limit) 7 + ids := make([]string, 0) 10 8 11 9 for _, hit := range h { 12 10 id, ok := hit.Fields["id"].(string) ··· 17 15 if !seen[id] { 18 16 seen[id] = true 19 17 ids = append(ids, id) 20 - } 21 - 22 - if len(ids) >= limit { 23 - break 24 18 } 25 19 } 26 20
+3 -9
internal/core/domain/search_id.go
··· 14 14 ) 15 15 16 16 // Format before encoding: {sequenceNumber}_{type}_{index1}[_{index2}] 17 - // - Kanji: {sequenceNumber}_k_{kanjiIndex} 18 - // - Reading: {sequenceNumber}_r_{readingIndex} 19 - // - Meaning: {sequenceNumber}_m_{senseIndex}_{glossIndex} 20 17 func CreateSearchID(sequenceNumber string, idType SearchIdType, index1, index2 int) string { 21 18 var str string 22 19 if index2 >= 0 { ··· 25 22 str = fmt.Sprintf("%s_%s_%d", sequenceNumber, idType, index1) 26 23 } 27 24 28 - // TODO: remove this after test 29 - fmt.Println(str) 30 - out := base32.StdEncoding.EncodeToString([]byte(str)) 31 - fmt.Println(out) 32 - return out 25 + // Before encoding: 5747532_m_0_0 26 + // After encoding: GU3TINZVGMZF63K7GBPTA=== 27 + return base32.StdEncoding.EncodeToString([]byte(str)) 33 28 } 34 29 35 - // Returns the decoded string in format: {sequenceNumber}_{type}_{index1}[_{index2}] 36 30 func ParseSearchID(encoded string) (string, error) { 37 31 decoded, err := base32.StdEncoding.DecodeString(encoded) 38 32 if err != nil {
+4 -4
web/static/css/style.css
··· 176 176 padding-left: var(--spacing-lg); 177 177 } 178 178 179 - .glosses li { 180 - margin-bottom: var(--spacing-xxs); 179 + .gloss { 180 + text-transform: capitalize; 181 181 } 182 182 183 - .glosses li::first-letter { 184 - text-transform: capitalize; 183 + .gloss:not(:last-child)::after { 184 + content: ";"; 185 185 } 186 186 187 187 .no-results {