Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions api/dbqueries/translation_queries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package dbqueries

import (
"fmt"
"strings"

"github.com/scribe-org/scribe-server/database"
"github.com/scribe-org/scribe-server/models"
)

// GetTranslationTableData fetches translation data for the given target and source language codes.
// It queries the TranslationData{TARGET}From{SOURCE} table and returns data nested as:
// word -> wordType -> wordOrder -> TranslationEntry.
func GetTranslationTableData(targetLang, sourceLang string) (map[string]map[string]map[string]models.TranslationEntry, error) {
tableName := fmt.Sprintf("TranslationData%sFrom%s",
strings.ToUpper(targetLang),
strings.ToUpper(sourceLang),
)

if !database.IsValidTranslationTableName(tableName) {
return nil, fmt.Errorf("invalid translation table name: %s", tableName)
}

exists, err := database.TableExists(tableName)
if err != nil {
return nil, fmt.Errorf("error checking table existence for %s: %w", tableName, err)
}
if !exists {
return nil, fmt.Errorf("translation table %s does not exist", tableName)
}

rows, err := database.DB.Query(
fmt.Sprintf("SELECT word, wordType, wordOrder, description, translation FROM `%s`", tableName),
)
if err != nil {
return nil, fmt.Errorf("error querying %s: %w", tableName, err)
}
defer rows.Close()

result := make(map[string]map[string]map[string]models.TranslationEntry)

for rows.Next() {
var word, wordType, wordOrder, description, translation string
if err := rows.Scan(&word, &wordType, &wordOrder, &description, &translation); err != nil {
return nil, fmt.Errorf("error scanning row: %w", err)
}

if result[word] == nil {
result[word] = make(map[string]map[string]models.TranslationEntry)
}
if result[word][wordType] == nil {
result[word][wordType] = make(map[string]models.TranslationEntry)
}
result[word][wordType][wordOrder] = models.TranslationEntry{
Description: description,
Translation: translation,
}
}

if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}

return result, nil
}
48 changes: 48 additions & 0 deletions api/handlers/language.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,54 @@ func GetContracts(c *gin.Context) {
})
}

// MARK: Translation Data Retrieval

// GetTranslationData returns translation data from a source language into a target language.
//
// @Summary Retrieve translation data
// @Description Returns nested translation data for the given target and source language ISO codes.
// @Tags Translations
// @Accept json
// @Produce json
// @Param source_lang path string true "Source language code (ISO 639-1)" example(de)
// @Param target_lang path string true "Target language code (ISO 639-1)" example(bn)
// @Success 200 {object} models.TranslationDataResponse "Successfully retrieved translation data"
// @Failure 400 {object} models.ErrorResponse "Invalid language code"
// @Failure 404 {object} models.ErrorResponse "Translation data not found"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /api/v1/translations [get]
func GetTranslationData(c *gin.Context) {
sourceLang := c.Query("source_lang")
targetLang := c.Query("target_lang")

if sourceLang == "" || targetLang == "" {
HandleError(c, http.StatusBadRequest, constants.EmptyTranslationCodeError)
return
}
Comment thread
DeleMike marked this conversation as resolved.

if !validators.IsValidTranslationLangCode(targetLang) || !validators.IsValidTranslationLangCode(sourceLang) {
HandleError(c, http.StatusBadRequest, constants.InvalidTranslationLangCodeError)
return
}

data, err := dbqueries.GetTranslationTableData(targetLang, sourceLang)
if err != nil {
log.Printf("Error fetching translation data for %s/%s: %v", targetLang, sourceLang, err)
if strings.Contains(err.Error(), "does not exist") {
HandleError(c, http.StatusNotFound, fmt.Sprintf("No translation data for '%s' from '%s'", targetLang, sourceLang))
return
}
HandleError(c, http.StatusInternalServerError, constants.ErrorFetchingTranslationData)
return
}

HandleSuccess(c, models.TranslationDataResponse{
TargetLang: targetLang,
SourceLang: sourceLang,
Data: data,
})
}

// MARK: Contract Loading Helpers

// loadSingleContract reads and unmarshals a single contract file.
Expand Down
1 change: 1 addition & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func SetupRoutes(r *gin.Engine) {
v1.GET("/languages", handlers.GetAvailableLanguages)
v1.GET("/contracts", handlers.GetContracts)
v1.GET("/language-stats", handlers.GetLanguageStats)
v1.GET("/translations", handlers.GetTranslationData)
}
}
}
11 changes: 6 additions & 5 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,12 @@ func startServer(r *gin.Engine) {

log.Printf("👀 Listening on port %s", hostPort)
log.Println("🚀 API Endpoints:")
log.Println(" ✅ GET /api/v1/languages - List available languages")
log.Println(" ✅ GET /api/v1/contracts[?lang_iso=xx] - Get contracts (optional language filter)")
log.Println(" ✅ GET /api/v1/data/:lang_iso - Get full language data with schema")
log.Println(" ✅ GET /api/v1/data-version/:lang_iso - Get version info for a language")
log.Println(" ✅ GET /api/v1/language-stats?codes=fr,de - Get statistics for all or selected languages")
log.Println(" ✅ GET /api/v1/languages - List available languages")
log.Println(" ✅ GET /api/v1/contracts[?lang_iso=xx] - Get contracts (optional language filter)")
log.Println(" ✅ GET /api/v1/data/:lang_iso - Get full language data with schema")
log.Println(" ✅ GET /api/v1/data-version/:lang_iso - Get version info for a language")
log.Println(" ✅ GET /api/v1/language-stats?codes=fr,de - Get statistics for all or selected languages")
log.Println(" ✅ GET /api/v1/translations?source_lang=es&target_lang=en - Get translation data of target from source")
log.Printf("📊 Available languages: %v", availableLanguages)

log.Fatal(r.Run(hostPort))
Expand Down
8 changes: 8 additions & 0 deletions api/validators/language_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package validators

import (
"regexp"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -52,3 +53,10 @@ func SanitizeLanguageCode(lang string) string {
func IsLanguageSupported(lang string, availableLanguages []string) bool {
return slices.Contains(availableLanguages, lang)
}

// IsValidTranslationLangCode checks if a language code is valid for translation endpoints.
// Accepts 2–4 lowercase ASCII letters (e.g. "bn", "de", "dag", "pnb").
func IsValidTranslationLangCode(lang string) bool {
matched, err := regexp.MatchString(`^[a-z]{2,4}$`, lang)
return err == nil && matched
}
24 changes: 14 additions & 10 deletions cmd/migrate/mariadb/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,25 @@ func generateMariaTableName(langCode, tableName string) string {
// Remove sqlite_ prefix if present.
cleanTableName := strings.TrimPrefix(tableName, "sqlite_")

// Clean up table name - replace underscores and capitalize properly.
cleanTableName = strings.ReplaceAll(cleanTableName, "_", "")

caser := cases.Title(language.English)
cleanTableName = caser.String(cleanTableName)

// Special handling for TranslationData tables.
if strings.HasPrefix(langCode, "TranslationData") {
// For TranslationData, just use TranslationData + Language:
// TranslationData + english -> TranslationDataEnglish
return "TranslationData" + cleanTableName
// Table names follow the pattern: {target}_translations_from_{source}
// e.g. bn_translations_from_de -> TranslationDataBNFromDE
if langCode == "TranslationData" {
parts := strings.Split(cleanTableName, "_")
if len(parts) == 4 && parts[1] == "translations" && parts[2] == "from" {
target := strings.ToUpper(parts[0])
source := strings.ToUpper(parts[3])
return "TranslationData" + target + "From" + source
}
// Fallback: strip underscores and title-case.
caser := cases.Title(language.English)
return "TranslationData" + caser.String(strings.ReplaceAll(cleanTableName, "_", ""))
}

// For language data tables, capitalize table name and add Scribe suffix:
// ENLanguageData + nouns -> ENLanguageDataNounsScribe
caser := cases.Title(language.English)
cleanTableName = caser.String(strings.ReplaceAll(cleanTableName, "_", ""))
return langCode + cleanTableName + "Scribe"
}

Expand Down
22 changes: 22 additions & 0 deletions database/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ func IsValidTableName(tableName string) bool {
return matched
}

// IsValidTranslationTableName validates TranslationData table names.
// Pattern: TranslationData{TARGET}From{SOURCE} e.g. TranslationDataBNFromDE.
func IsValidTranslationTableName(tableName string) bool {
pattern := `^TranslationData[A-Z]{2,4}From[A-Z]{2,4}$`
matched, err := regexp.MatchString(pattern, tableName)
if err != nil || !matched {
return false
}

if len(tableName) > 60 || len(tableName) < 23 {
return false
}

for _, char := range tableName {
if !constants.IsAlphaNumeric(char) {
return false
}
}

return true
}

// MARK: Pointer Conversion

// ToIntPtr converts various numeric types to a pointer to int.
Expand Down
98 changes: 98 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,65 @@ const docTemplate = `{
}
}
}
},
"/api/v1/translations": {
"get": {
"description": "Returns nested translation data for the given target and source language ISO codes.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Translations"
],
"summary": "Retrieve translation data",
"parameters": [
{
"type": "string",
"example": "de",
"description": "Source language code (ISO 639-1)",
"name": "source_lang",
"in": "path",
"required": true
},
{
"type": "string",
"example": "bn",
"description": "Target language code (ISO 639-1)",
"name": "target_lang",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Successfully retrieved translation data",
"schema": {
"$ref": "#/definitions/models.TranslationDataResponse"
}
},
"400": {
"description": "Invalid language code",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "Translation data not found",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
}
},
"definitions": {
Expand Down Expand Up @@ -385,6 +444,45 @@ const docTemplate = `{
}
}
}
},
"models.TranslationDataResponse": {
"type": "object",
"properties": {
"data": {
"description": "Nested translation data",
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/models.TranslationEntry"
}
}
}
},
"source_lang": {
"description": "ISO code of the source language (e.g. \"de\")",
"type": "string"
},
"target_lang": {
"description": "ISO code of the target language (e.g. \"bn\")",
"type": "string"
}
}
},
"models.TranslationEntry": {
"type": "object",
"properties": {
"description": {
"description": "Description of the word in the source language",
"type": "string"
},
"translation": {
"description": "Translation of the word in the target language",
"type": "string"
}
}
}
},
"externalDocs": {
Expand Down
Loading
Loading