English/Japanese dictionary

update

+198 -236
+1 -4
README.md
··· 17 17 This project expects the JMdict file uncompressed in the root folder by default. 18 18 19 19 ```bash 20 - wget ftp://ftp.edrdg.org/pub/Nihongo//JMdict_e.gz 21 - gunzip JMdict.gz 20 + curl ftp://ftp.edrdg.org/pub/Nihongo//JMdict_e.gz | gunzip > JMdict_e 22 21 ``` 23 22 24 23 ## HTMX 25 24 26 25 The frontend is built using templates. The client will fetch the dictionary content through HTMX. 27 26 The current version, found in `web/static/js`, is [`2.0.8`](https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/). 28 - 29 - I will move to [HTMX 4](https://four.htmx.org/htmx-4/) eventually to add request caching in the client side.
+11 -7
cmd/main.go
··· 7 7 "dictionary-api/internal/adapters/secondary/parser" 8 8 "dictionary-api/internal/adapters/secondary/persistence" 9 9 "dictionary-api/internal/adapters/secondary/search" 10 - "dictionary-api/internal/adapters/secondary/services" 11 10 "dictionary-api/internal/application/usecases" 12 11 "fmt" 13 12 "log" ··· 15 14 ) 16 15 17 16 func main() { 17 + // Loading environment 18 18 cfg := config.NewConfigRepository() 19 - 20 19 configValues := cfg.Values() 21 20 21 + // Logger initialize 22 22 logger := logging.NewLogger(configValues.LogGroup, configValues.LogStream) 23 23 24 + // Mongo connection 24 25 logger.Info("Connecting to MongoDB...") 25 26 connectionString := fmt.Sprintf("mongodb://%s:%s", configValues.HostIp, configValues.MongoPort) 26 27 mongoRepo, err := persistence.NewMongoEntryRepository(connectionString, "jmdict", "entries") ··· 29 30 } 30 31 defer mongoRepo.Close() 31 32 33 + // Bleve connection 32 34 logger.Info("Connecting to Bleve Index...") 33 35 bleveIndex, err := search.NewBleveSearchRepository(configValues.BleveIndexPath) 34 36 if err != nil { 35 37 log.Fatal(err) 36 38 } 37 39 40 + // The rest of secondary adapters... 38 41 xmlParser := parser.NewXMLParserRepository() 39 - searchIdService := services.NewSearchIdService() 40 42 41 - importUseCase := usecases.NewImportUseCase(mongoRepo, bleveIndex, xmlParser, logger) 42 - searchUseCase := usecases.NewSearchUseCase(mongoRepo, bleveIndex, searchIdService, logger) 43 - 43 + // Primary adapters 44 + responseWriter := httpAdapter.NewResponseWriter(logger) 44 45 templateRenderer, err := httpAdapter.NewTemplateRenderer() 45 46 if err != nil { 46 47 log.Fatal("Failed to initialize template renderer:", err) 47 48 } 48 49 49 - responseWriter := httpAdapter.NewResponseWriter(logger) 50 + // Use cases 51 + importUseCase := usecases.NewImportUseCase(mongoRepo, bleveIndex, xmlParser, logger) 52 + searchUseCase := usecases.NewSearchUseCase(mongoRepo, bleveIndex, logger) 50 53 54 + // HTTP Server and their handlers 51 55 httpHandler := httpAdapter.NewHandler(logger, importUseCase, searchUseCase, responseWriter, templateRenderer) 52 56 53 57 mux := http.NewServeMux()
+80 -20
internal/adapters/primary/http/handlers.go
··· 2 2 3 3 import ( 4 4 "dictionary-api/internal/application/usecases" 5 + "dictionary-api/internal/core/domain" 5 6 "dictionary-api/internal/core/ports" 7 + "fmt" 6 8 "net/http" 9 + "strconv" 10 + "strings" 7 11 ) 8 12 9 13 type Handler struct { ··· 35 39 36 40 // API routes 37 41 mux.HandleFunc("POST /v1/jisho/import", validateBearer(h.HandleImport, developmentToken)) 38 - h.logger.Info("├── POST /v1/jisho/import (requires Bearer token)") 39 42 mux.HandleFunc("GET /v1/jisho/search", h.HandleSearch) 40 - h.logger.Info("├── GET /v1/jisho/search") 43 + 41 44 // Frontend routes 42 45 mux.HandleFunc("GET /", h.HandleIndex) 43 - h.logger.Info("├── GET /") 46 + 44 47 // Static file serving 45 48 mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) 49 + 50 + // Draw pretty tree 51 + h.logger.Info("├── GET /") 52 + h.logger.Info("├── GET /v1/jisho/search") 53 + h.logger.Info("├── POST /v1/jisho/import (requires Bearer token)") 46 54 h.logger.Info("└── GET /static/*") 47 55 } 48 56 ··· 65 73 h.responseWriter.WriteHTML(w, http.StatusOK, h.templateRenderer, "index.html", nil) 66 74 } 67 75 76 + func validateSearchParams(r *http.Request) (*usecases.SearchRequest, *domain.ResponseError) { 77 + // - Search term 78 + term := strings.TrimSpace(r.URL.Query().Get("q")) 79 + 80 + if term == "" { 81 + return nil, &domain.ResponseError{ 82 + StatusCode: http.StatusBadRequest, 83 + Message: "Search term is missing in the query parameter 'q'", 84 + } 85 + } 86 + 87 + if len(term) > 500 { 88 + return nil, &domain.ResponseError{ 89 + StatusCode: http.StatusBadRequest, 90 + Message: "Search term exceeds maximum length of 500 characters", 91 + } 92 + } 93 + 94 + // - Limit 95 + limit := 10 // Default to 10 items per search 96 + 97 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 98 + parsedLimit, err := strconv.Atoi(limitStr) 99 + if err != nil { 100 + return nil, &domain.ResponseError{ 101 + StatusCode: http.StatusBadRequest, 102 + Message: fmt.Sprintf("Invalid limit parameter: must be a number, got '%s'", limitStr), 103 + } 104 + } 105 + 106 + if parsedLimit < 1 { 107 + return nil, &domain.ResponseError{ 108 + StatusCode: http.StatusBadRequest, 109 + Message: "Limit parameter must be greater than 0", 110 + } 111 + } 112 + 113 + if parsedLimit > 20 { 114 + return nil, &domain.ResponseError{ 115 + StatusCode: http.StatusBadRequest, 116 + Message: "Limit parameter exceeds maximum of 20", 117 + } 118 + } 119 + 120 + limit = parsedLimit 121 + } 122 + 123 + return &usecases.SearchRequest{ 124 + Term: term, 125 + Limit: limit, 126 + }, nil 127 + } 128 + 68 129 func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) { 69 - // TODO: Improve query string validation. Add the possibility to read 'limit' too. 70 - term := r.URL.Query().Get("q") 130 + h.logger.Info("Handling search request") 71 131 72 - h.logger.Info("Handling search request: %s", term) 132 + // Validate and extract query parameters 133 + searchReq, validationErr := validateSearchParams(r) 134 + if validationErr != nil { 135 + h.logger.Error("Validation error: %s", validationErr.Message) 73 136 74 - // Handle 400: empty query content 75 - if term == "" { 76 137 // For HTMX requests, return empty results; for API requests, return error 77 138 if isHTMXRequest(r) { 78 139 h.responseWriter.WriteHTML(w, http.StatusOK, h.templateRenderer, ··· 80 141 return 81 142 } 82 143 83 - message := "Search term is missing in the query parameter 'q'" 84 - h.logger.Error(message) 85 - h.responseWriter.WriteJSONError(w, http.StatusBadRequest, message) 144 + h.responseWriter.WriteJSONError(w, validationErr.StatusCode, validationErr.Message) 86 145 return 87 146 } 88 147 89 - searchReq := usecases.SearchRequest{ 90 - Term: term, 91 - Limit: 10, 92 - } 93 - 94 - result, err := h.searchUseCase.Execute(r.Context(), &searchReq) 148 + h.logger.Info("Searching for term: %s (limit: %d)", searchReq.Term, searchReq.Limit) 149 + result, err := h.searchUseCase.Execute(r.Context(), searchReq) 95 150 96 151 // Handle 500: internal database issue 97 152 if err != nil { 153 + internalServerErr := &domain.ResponseError{ 154 + StatusCode: http.StatusInternalServerError, 155 + Message: "Search failed", 156 + } 157 + 98 158 h.logger.Error("Search failed: %s", err.Error()) 99 159 100 160 if isHTMXRequest(r) { 101 - h.responseWriter.WriteHTML(w, http.StatusInternalServerError, h.templateRenderer, 102 - "search_results.html", map[string]any{"Entries": nil, "Error": "Search failed"}) 161 + h.responseWriter.WriteHTML(w, internalServerErr.StatusCode, h.templateRenderer, 162 + "search_results.html", map[string]any{"Entries": nil, "Error": internalServerErr.Message}) 103 163 return 104 164 } 105 165 106 - h.responseWriter.WriteJSONError(w, http.StatusInternalServerError, "Search failed") 166 + h.responseWriter.WriteJSONError(w, internalServerErr.StatusCode, internalServerErr.Message) 107 167 return 108 168 } 109 169
-34
internal/adapters/secondary/persistence/mongodb.go
··· 5 5 "dictionary-api/internal/core/domain" 6 6 "dictionary-api/internal/core/ports" 7 7 "fmt" 8 - "sort" 9 8 10 9 "go.mongodb.org/mongo-driver/bson" 11 10 "go.mongodb.org/mongo-driver/mongo" ··· 116 115 return score 117 116 } 118 117 119 - func SortByPriority(entries []*domain.Entry) { 120 - // NOTE: a map with the score of those already calculated entries would help. 121 - // But after benchmarking, sorting adds no important delay with 10 entries tops per request. 122 - sort.SliceStable(entries, func(i, j int) bool { 123 - // Collect all priorities from entry I 124 - prioritiesI := make([]*domain.Priority, 0) 125 - for _, kanji := range entries[i].KanjiElements { 126 - prioritiesI = append(prioritiesI, &kanji.Priority) 127 - } 128 - for _, reading := range entries[i].ReadingElements { 129 - prioritiesI = append(prioritiesI, &reading.Priority) 130 - } 131 - scoreI := calculatePriorityScore(prioritiesI...) 132 - 133 - // Collect all priorities from entry J 134 - prioritiesJ := make([]*domain.Priority, 0) 135 - for _, kanji := range entries[j].KanjiElements { 136 - prioritiesJ = append(prioritiesJ, &kanji.Priority) 137 - } 138 - for _, reading := range entries[j].ReadingElements { 139 - prioritiesJ = append(prioritiesJ, &reading.Priority) 140 - } 141 - scoreJ := calculatePriorityScore(prioritiesJ...) 142 - 143 - // Higher priority value goes first 144 - return scoreI > scoreJ 145 - }) 146 - } 147 - 148 118 func (r *MongoEntryRepository) GetByIDs(ctx context.Context, entryIDs []string) ([]*domain.Entry, error) { 149 119 if len(entryIDs) == 0 { 150 120 return []*domain.Entry{}, nil ··· 171 141 Senses: dbEntry.Senses, 172 142 } 173 143 } 174 - 175 - // TODO: Not sure if this method should mutate the original entries or create a new list instead 176 - // Also, move it as a Service 177 - SortByPriority(entries) 178 144 179 145 return entries, nil 180 146 }
+38 -98
internal/adapters/secondary/search/bleve.go
··· 1 1 package search 2 2 3 3 import ( 4 - "dictionary-api/internal/adapters/secondary/services" 5 4 "dictionary-api/internal/core/domain" 6 5 "dictionary-api/internal/core/ports" 7 6 "os" 8 - "sort" 9 7 "strings" 10 8 11 9 "github.com/blevesearch/bleve/v2" 12 10 "github.com/blevesearch/bleve/v2/analysis/lang/cjk" 13 11 "github.com/blevesearch/bleve/v2/mapping" 12 + "github.com/blevesearch/bleve/v2/search" 14 13 ) 15 14 16 15 type TextSearchEntry struct { 17 - ID string `json:"id"` 18 - Kanji string `json:"kanji,omitempty"` 19 - Reading string `json:"reading,omitempty"` 20 - Meaning string `json:"meaning,omitempty"` 21 - Priority string `json:"priority,omitempty"` 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"` 22 22 } 23 23 24 24 type BleveSearchRepository struct { 25 - index bleve.Index 26 - searchIDService *services.SearchIdService 25 + index bleve.Index 27 26 } 28 27 29 28 // TODO: Code related with Bleve and Search collection should be separated at some point ··· 46 45 } 47 46 48 47 return &BleveSearchRepository{ 49 - index: index, 50 - searchIDService: services.NewSearchIdService(), 48 + index: index, 51 49 }, nil 52 50 } 53 51 ··· 80 78 priorityFieldMapping := bleve.NewTextFieldMapping() 81 79 priorityFieldMapping.Store = true 82 80 priorityFieldMapping.Index = true 83 - // User won't run such a thing like "news1" search. 84 - // 'IncludeInAll false' exclude the field from general search. 81 + // Avoid results with a search term matching priority tags like "ichi1" or "news2" 85 82 priorityFieldMapping.IncludeInAll = false 86 83 searchDocMapping.AddFieldMappingsAt("priority", priorityFieldMapping) 87 84 88 85 indexMapping.AddDocumentMapping("search", searchDocMapping) 89 86 indexMapping.DefaultMapping = searchDocMapping 90 - indexMapping.ScoringModel = "bm25" 91 87 92 88 return indexMapping 93 89 } ··· 104 100 for i, kanji := range entry.KanjiElements { 105 101 if kanji.Text != "" { 106 102 doc := TextSearchEntry{ 107 - ID: entry.SequenceNumber, 108 - Kanji: kanji.Text, 109 - Priority: strings.Join(kanji.Priority, " "), 110 - } 111 - searchID := domain.SearchId{ 112 - SequenceNumber: entry.SequenceNumber, 113 - Type: domain.SearchIDTypeKanji, 114 - Index1: i, 115 - Index2: -1, 103 + ID: entry.SequenceNumber, 104 + SortIndex: i, 105 + Kanji: kanji.Text, 106 + Priority: strings.Join(kanji.Priority, " "), 116 107 } 117 - documents.Index(r.searchIDService.Stringify(searchID), doc) 108 + searchID := domain.CreateSearchID(entry.SequenceNumber, domain.SearchIDTypeKanji, i, -1) 109 + documents.Index(searchID, doc) 118 110 } 119 111 } 120 112 121 113 for i, reading := range entry.ReadingElements { 122 114 if reading.Text != "" { 123 115 doc := TextSearchEntry{ 124 - ID: entry.SequenceNumber, 125 - Reading: reading.Text, 126 - Priority: strings.Join(reading.Priority, " "), 127 - } 128 - searchID := domain.SearchId{ 129 - SequenceNumber: entry.SequenceNumber, 130 - Type: domain.SearchIDTypeReading, 131 - Index1: i, 132 - Index2: -1, 116 + ID: entry.SequenceNumber, 117 + SortIndex: i, 118 + Reading: reading.Text, 119 + Priority: strings.Join(reading.Priority, " "), 133 120 } 134 - documents.Index(r.searchIDService.Stringify(searchID), doc) 121 + searchID := domain.CreateSearchID(entry.SequenceNumber, domain.SearchIDTypeReading, i, -1) 122 + documents.Index(searchID, doc) 135 123 } 136 124 } 137 125 ··· 139 127 for j, gloss := range sense.Glosses { 140 128 if gloss != "" { 141 129 doc := TextSearchEntry{ 142 - ID: entry.SequenceNumber, 143 - Meaning: gloss, 130 + ID: entry.SequenceNumber, 131 + SortIndex: i, // The sorting preference is focused on the sense order only 132 + Meaning: gloss, 144 133 } 145 - searchID := domain.SearchId{ 146 - SequenceNumber: entry.SequenceNumber, 147 - Type: domain.SearchIDTypeMeaning, 148 - Index1: i, 149 - Index2: j, 150 - } 151 - documents.Index(r.searchIDService.Stringify(searchID), doc) 134 + searchID := domain.CreateSearchID(entry.SequenceNumber, domain.SearchIDTypeMeaning, i, j) 135 + documents.Index(searchID, doc) 152 136 } 153 137 } 154 138 } ··· 162 146 termQuery := bleve.NewMatchQuery(term) 163 147 164 148 searchRequest := bleve.NewSearchRequest(termQuery) 165 - searchRequest.Size = 100 // Take big number to make sure there's enough data to build a page (10 items default) 149 + searchRequest.Size = limit 166 150 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 + }) 167 160 168 161 searchResult, err := r.index.Search(searchRequest) 169 162 if err != nil { 170 163 return nil, err 171 164 } 172 165 173 - // Parse and sort hit IDs based on indexes 174 - type hitWithIndexes struct { 175 - sequenceNum string 176 - index1 int 177 - index2 int 178 - } 179 - 180 - hits := make([]hitWithIndexes, 0, len(searchResult.Hits)) 181 - for _, hit := range searchResult.Hits { 182 - id, ok := hit.Fields["id"].(string) 183 - if !ok { 184 - continue 185 - } 186 - 187 - searchID, err := r.searchIDService.Parse(hit.ID) 188 - if err != nil { 189 - continue 190 - } 191 - 192 - index2 := searchID.Index2 193 - if index2 == -1 { 194 - index2 = 0 195 - } 196 - 197 - hits = append(hits, hitWithIndexes{ 198 - sequenceNum: id, 199 - index1: searchID.Index1, 200 - index2: index2, 201 - }) 202 - } 203 - 204 - // Sort by index1 first, then index2 205 - sort.Slice(hits, func(i, j int) bool { 206 - if hits[i].index1 != hits[j].index1 { 207 - return hits[i].index1 < hits[j].index1 208 - } 209 - return hits[i].index2 < hits[j].index2 210 - }) 211 - 212 - // Deduplicate and take first 10 213 - seen := make(map[string]bool) 214 - ids := make([]string, 0, 10) 215 - 216 - for _, hit := range hits { 217 - if !seen[hit.sequenceNum] { 218 - seen[hit.sequenceNum] = true 219 - ids = append(ids, hit.sequenceNum) 220 - 221 - if len(ids) >= 10 { 222 - break 223 - } 224 - } 225 - } 226 - 166 + ids := Hits(searchResult.Hits).Deduplicate(limit) 227 167 return ids, nil 228 168 } 229 169
+28
internal/adapters/secondary/search/hits.go
··· 1 + package search 2 + 3 + import "github.com/blevesearch/bleve/v2/search" 4 + 5 + type Hits []*search.DocumentMatch 6 + 7 + func (h Hits) Deduplicate(limit int) []string { 8 + seen := make(map[string]bool) 9 + ids := make([]string, 0, limit) 10 + 11 + for _, hit := range h { 12 + id, ok := hit.Fields["id"].(string) 13 + if !ok { 14 + continue 15 + } 16 + 17 + if !seen[id] { 18 + seen[id] = true 19 + ids = append(ids, id) 20 + } 21 + 22 + if len(ids) >= limit { 23 + break 24 + } 25 + } 26 + 27 + return ids 28 + }
-50
internal/adapters/secondary/services/search_id.go
··· 1 - package services 2 - 3 - import ( 4 - "dictionary-api/internal/core/domain" 5 - "fmt" 6 - "strconv" 7 - "strings" 8 - ) 9 - 10 - type SearchIdService struct{} 11 - 12 - func NewSearchIdService() *SearchIdService { 13 - return &SearchIdService{} 14 - } 15 - 16 - func (s *SearchIdService) Parse(id string) (*domain.SearchId, error) { 17 - parts := strings.Split(id, "_") 18 - if len(parts) < 3 { 19 - return nil, fmt.Errorf("invalid search ID format: %s", id) 20 - } 21 - 22 - searchID := &domain.SearchId{ 23 - SequenceNumber: parts[0], 24 - Type: domain.SearchIdType(parts[1]), 25 - Index2: -1, // Default to not set 26 - } 27 - 28 - index1, err := strconv.Atoi(parts[2]) 29 - if err != nil { 30 - return nil, fmt.Errorf("invalid index1 in search ID: %s", id) 31 - } 32 - searchID.Index1 = index1 33 - 34 - if len(parts) >= 4 { 35 - index2, err := strconv.Atoi(parts[3]) 36 - if err != nil { 37 - return nil, fmt.Errorf("invalid index2 in search ID: %s", id) 38 - } 39 - searchID.Index2 = index2 40 - } 41 - 42 - return searchID, nil 43 - } 44 - 45 - func (s *SearchIdService) Stringify(searchId domain.SearchId) string { 46 - if searchId.Index2 >= 0 { 47 - return fmt.Sprintf("%s_%s_%d_%d", searchId.SequenceNumber, searchId.Type, searchId.Index1, searchId.Index2) 48 - } 49 - return fmt.Sprintf("%s_%s_%d", searchId.SequenceNumber, searchId.Type, searchId.Index1) 50 - }
+6 -11
internal/application/usecases/search.go
··· 16 16 } 17 17 18 18 type SearchUseCase struct { 19 - repo ports.EntryRepository 20 - index ports.SearchRepository 21 - searchIdService ports.SearchIdServiceInterface 22 - logger ports.LoggerInterface 19 + repo ports.EntryRepository 20 + index ports.SearchRepository 21 + logger ports.LoggerInterface 23 22 } 24 23 25 24 func NewSearchUseCase( 26 25 repo ports.EntryRepository, 27 26 index ports.SearchRepository, 28 - searchIdService ports.SearchIdServiceInterface, 29 27 logger ports.LoggerInterface, 30 28 ) *SearchUseCase { 31 29 return &SearchUseCase{ 32 - repo: repo, 33 - index: index, 34 - searchIdService: searchIdService, 35 - logger: logger, 30 + repo: repo, 31 + index: index, 32 + logger: logger, 36 33 } 37 34 } 38 35 39 36 func (uc *SearchUseCase) Execute(ctx context.Context, req *SearchRequest) (*SearchResponse, error) { 40 - // TODO: would be awesome to check if the whole term is written in romaji. 41 - // In that case I could run the search with their translation to hiragana/katakana 42 37 ids, _ := uc.index.Search(req.Term, req.Limit) 43 38 entries, _ := uc.repo.GetByIDs(ctx, ids) 44 39 return &SearchResponse{Entries: entries}, nil
+6
internal/core/domain/response_error.go
··· 1 + package domain 2 + 3 + type ResponseError struct { 4 + StatusCode int 5 + Message string 6 + }
+28 -7
internal/core/domain/search_id.go
··· 1 1 package domain 2 2 3 + import ( 4 + "encoding/base32" 5 + "fmt" 6 + ) 7 + 3 8 type SearchIdType string 4 9 5 10 const ( ··· 8 13 SearchIDTypeMeaning SearchIdType = "m" 9 14 ) 10 15 11 - // SearchId represents a composite identifier used in the search index. 12 - // Format: {sequenceNumber}_{type}_{index1}[_{index2}] 16 + // Format before encoding: {sequenceNumber}_{type}_{index1}[_{index2}] 13 17 // - Kanji: {sequenceNumber}_k_{kanjiIndex} 14 18 // - Reading: {sequenceNumber}_r_{readingIndex} 15 19 // - Meaning: {sequenceNumber}_m_{senseIndex}_{glossIndex} 16 - type SearchId struct { 17 - SequenceNumber string 18 - Type SearchIdType 19 - Index1 int 20 - Index2 int // -1 means not set (only used for meanings) 20 + func CreateSearchID(sequenceNumber string, idType SearchIdType, index1, index2 int) string { 21 + var str string 22 + if index2 >= 0 { 23 + str = fmt.Sprintf("%s_%s_%d_%d", sequenceNumber, idType, index1, index2) 24 + } else { 25 + str = fmt.Sprintf("%s_%s_%d", sequenceNumber, idType, index1) 26 + } 27 + 28 + // TODO: remove this after test 29 + fmt.Println(str) 30 + out := base32.StdEncoding.EncodeToString([]byte(str)) 31 + fmt.Println(out) 32 + return out 33 + } 34 + 35 + // Returns the decoded string in format: {sequenceNumber}_{type}_{index1}[_{index2}] 36 + func ParseSearchID(encoded string) (string, error) { 37 + decoded, err := base32.StdEncoding.DecodeString(encoded) 38 + if err != nil { 39 + return "", fmt.Errorf("failed to decode base32: %w", err) 40 + } 41 + return string(decoded), nil 21 42 }
-5
internal/core/ports/interfaces.go
··· 21 21 ParseEntries(filePath string, entities map[string]string) (<-chan []*domain.Entry, <-chan error) 22 22 } 23 23 24 - type SearchIdServiceInterface interface { 25 - Parse(id string) (*domain.SearchId, error) 26 - Stringify(searchID domain.SearchId) string 27 - } 28 - 29 24 type LoggerInterface interface { 30 25 Debug(message string, args ...interface{}) 31 26 Info(message string, args ...interface{})