package productTranslationService
import (
"context"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"slices"
"strings"
"time"
"cloud.google.com/go/auth/credentials"
translate "cloud.google.com/go/translate/apiv3"
"cloud.google.com/go/translate/apiv3/translatepb"
"git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/productDescriptionRepo"
"git.ma-al.com/goc_daniel/b2b/app/service/langsService"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
googleopt "google.golang.org/api/option"
)
type ProductTranslationService struct {
productDescriptionRepo productDescriptionRepo.UIProductDescriptionRepo
ctx context.Context
googleCli translate.TranslationClient
projectID string
openAIClient openai.Client
}
// New creates a ProductTranslationService and authenticates against the
// Google Cloud Translation API using a service account key file.
//
// Required configuration (set in .env or environment):
//
// GOOGLE_APPLICATION_CREDENTIALS – absolute path to the service account JSON key file
// GOOGLE_CLOUD_PROJECT_ID – your Google Cloud project ID
//
// The service account must have the "Cloud Translation API User" role
// (roles/cloudtranslate.user) granted in Google Cloud IAM.
func New() *ProductTranslationService {
ctx := context.Background()
cfg := config.Get()
// Read the service account key file whose path comes from config / env.
data, err := os.ReadFile(cfg.GoogleTranslate.CredentialsFile)
if err != nil {
log.Fatalf("ProductTranslationService: cannot read credentials file %q: %v",
cfg.GoogleTranslate.CredentialsFile, err)
}
// Build OAuth2 credentials scoped to the Cloud Translation API.
// The correct scope for Cloud Translation v3 is "cloud-translation".
creds, err := credentials.DetectDefault(&credentials.DetectOptions{
Scopes: []string{"https://www.googleapis.com/auth/cloud-translation"},
CredentialsJSON: data,
})
if err != nil {
log.Fatalf("ProductTranslationService: cannot build Google credentials: %v", err)
}
googleCli, err := translate.NewTranslationClient(ctx, googleopt.WithAuthCredentials(creds))
if err != nil {
log.Fatalf("ProductTranslationService: cannot create Translation client: %v", err)
}
openAIClient := openai.NewClient(option.WithAPIKey(os.Getenv("OPENAI_KEY")),
option.WithHTTPClient(&http.Client{Timeout: 300 * time.Second})) // five minutes timeout
return &ProductTranslationService{
productDescriptionRepo: productDescriptionRepo.New(),
ctx: ctx,
openAIClient: openAIClient,
googleCli: *googleCli,
projectID: cfg.GoogleTranslate.ProjectID,
}
}
func (s *ProductTranslationService) GetProductDescription(userID uint, productID uint, productLangID uint) (*model.ProductDescription, error) {
return s.productDescriptionRepo.GetProductDescription(productID, productLangID)
}
// Updates relevant fields with the "updates" map
func (s *ProductTranslationService) SaveProductDescription(userID uint, productID uint, productLangID uint, updates map[string]string) error {
// only some fields can be affected
allowedFields := []string{"description", "description_short", "meta_description", "meta_title", "name", "available_now", "available_later", "usage"}
for key := range updates {
if !slices.Contains(allowedFields, key) {
return responseErrors.ErrBadField
}
}
// check that fields description, description_short and usage, if they exist, have a valid html format
mustBeHTML := []string{"description", "description_short", "usage"}
for i := 0; i < len(mustBeHTML); i++ {
if _, exists := updates[mustBeHTML[i]]; exists {
updates[mustBeHTML[i]] = parseAutoCloseTags(updates[mustBeHTML[i]])
if !isValidXHTML(updates[mustBeHTML[i]]) {
return responseErrors.ErrInvalidXHTML
}
}
}
err := s.productDescriptionRepo.CreateIfDoesNotExist(productID, productLangID)
if err != nil {
return err
}
return s.productDescriptionRepo.UpdateFields(productID, productLangID, updates)
}
// TranslateProductDescription fetches the product description for productFromLangID,
// translates every text field into productToLangID using the Google Cloud
// Translation API (v3 TranslateText), and returns the translated record.
//
// The Google Cloud project must have the Cloud Translation API enabled and the
// service account must hold the "Cloud Translation API User" role.
func (s *ProductTranslationService) TranslateProductDescription(userID uint, productID uint, productFromLangID uint, productToLangID uint, aiModel string) (*model.ProductDescription, error) {
productDescription, err := s.productDescriptionRepo.GetProductDescription(productID, productFromLangID)
if err != nil {
return nil, err
}
productDescription.LangID = productToLangID
// we translate all changeable fields, and we keep the exact same HTML structure in relevant fields.
lang, err := langsService.LangSrv.GetLanguageById(productToLangID)
if err != nil {
return nil, err
}
fields := []*string{&productDescription.Description,
&productDescription.DescriptionShort,
&productDescription.MetaDescription,
&productDescription.MetaTitle,
&productDescription.Name,
&productDescription.AvailableNow,
&productDescription.AvailableLater,
&productDescription.Usage,
}
keys := []string{"translation_of_product_description",
"translation_of_product_short_description",
"translation_of_product_meta_description",
"translation_of_product_meta_title",
"translation_of_product_name",
"translation_of_product_available_now",
"translation_of_product_available_later",
"translation_of_product_usage",
}
request := ""
if aiModel == "OpenAI" {
request = "Translate to " + lang.ISOCode + " without changing the html structure.\n"
}
for i := 0; i < len(keys); i++ {
request += "\n<" + keys[i] + ">"
request += *fields[i]
request += "" + keys[i] + ">\n"
}
if aiModel == "OpenAI" {
request = cleanForPrompt(request)
}
if aiModel == "OpenAI" {
response, _ := s.openAIClient.Responses.New(context.Background(), responses.ResponseNewParams{
Input: responses.ResponseNewParamsInputUnion{OfString: openai.String(request)},
Model: openai.ChatModelGPT4_1Mini,
// Model: openai.ChatModelGPT4_1Nano,
})
if response.Status != "completed" {
return nil, responseErrors.ErrAIResponseFail
}
for i := 0; i < len(keys); i++ {
success, resolution := resolveResponse(*fields[i], response.OutputText(), keys[i])
if !success {
return nil, responseErrors.ErrAIBadOutput
}
*fields[i] = resolution
// fmt.Println(resolution)
}
} else if aiModel == "Google" {
// TranslateText is the standard Cloud Translation v3 endpoint.
req := &translatepb.TranslateTextRequest{
Parent: fmt.Sprintf("projects/%s/locations/global", s.projectID),
TargetLanguageCode: lang.ISOCode,
MimeType: "text/html",
Contents: []string{request},
}
responseGoogle, err := s.googleCli.TranslateText(s.ctx, req)
if err != nil {
return nil, err
}
// TranslateText returns one Translation per input string.
if len(responseGoogle.GetTranslations()) == 0 {
return nil, responseErrors.ErrAIBadOutput
}
response := responseGoogle.GetTranslations()[0].GetTranslatedText()
for i := 0; i < len(keys); i++ {
success, match := getStringInBetween(response, "<"+keys[i]+">", ""+keys[i]+">")
if !success || !isValidXHTML(match) {
return nil, responseErrors.ErrAIBadOutput
}
*fields[i] = match
// fmt.Println(match)
}
}
return productDescription, nil
}
func cleanForPrompt(s string) string {
r := strings.NewReader(s)
d := xml.NewDecoder(r)
prompt := ""
// Configure the decoder for HTML; leave off strict and autoclose for XHTML
d.Strict = true
d.AutoClose = xml.HTMLAutoClose
d.Entity = xml.HTMLEntity
for {
token, err := d.Token()
if err == io.EOF {
break
}
switch v := token.(type) {
case xml.StartElement:
prompt += "<" + attrName(v.Name)
for _, attr := range v.Attr {
if v.Name.Local == "img" && attr.Name.Local == "alt" {
prompt += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
}
}
if slices.Contains(xml.HTMLAutoClose, v.Name.Local) {
prompt += "/>"
} else {
prompt += ">"
}
case xml.EndElement:
if !slices.Contains(xml.HTMLAutoClose, v.Name.Local) {
prompt += "" + attrName(v.Name) + ">"
}
case xml.CharData:
prompt += string(v)
case xml.Comment:
case xml.ProcInst:
case xml.Directive:
}
}
return prompt
}
func resolveResponse(original string, response string, key string) (bool, string) {
success, match := getStringInBetween(response, "<"+key+">", ""+key+">")
if !success || !isValidXHTML(match) {
return false, ""
}
success, resolution := rebuildFromResponse("<"+key+">"+original+""+key+">", "<"+key+">"+match+""+key+">")
if !success {
return false, ""
}
return true, resolution[2+len(key) : len(resolution)-3-len(key)]
}
// getStringInBetween returns empty string if no start or end string found
func getStringInBetween(str string, start string, end string) (success bool, result string) {
s := strings.Index(str, start)
if s == -1 {
return false, ""
}
s += len(start)
e := strings.Index(str[s:], end)
if e == -1 {
return false, ""
}
return true, str[s : s+e]
}
// this converts input into HTML4 format.
// this really is ad-hoc solution, but it works.
func parseAutoCloseTags(s string) string {
alts := ""
for i, name := range xml.HTMLAutoClose {
if i > 0 {
alts += "|"
}
alts += name
}
// remove closing tags
reClose := regexp.MustCompile(`(?i)<\s*\/\s*(?:` + alts + `)\s*>`)
s = reClose.ReplaceAllString(s, "")
// convert
→
// matches
that do NOT already end with />
reOpen := regexp.MustCompile(`(?i)<\s*(` + alts + `)\b([^>]*?)>`)
s = reOpen.ReplaceAllStringFunc(s, func(tag string) string {
trimmed := strings.TrimSpace(tag)
// Already self-closed:
,
if strings.HasSuffix(trimmed, "/>") {
return tag
}
// Replace final > with />
i := strings.LastIndex(tag, ">")
if i < 0 {
return tag
}
return tag[:i] + " />"
})
return s
}
// isValidXHTML checks if the string obeys the XHTML format
func isValidXHTML(s string) bool {
r := strings.NewReader(s)
d := xml.NewDecoder(r)
// Configure the decoder for HTML; leave off strict and autoclose for XHTML
d.Strict = true
d.AutoClose = xml.HTMLAutoClose
d.Entity = xml.HTMLEntity
for {
_, err := d.Token()
switch err {
case io.EOF:
return true // We're done, it's valid!
case nil:
default:
return false // Oops, something wasn't right
}
}
}
// Rebuilds HTML using the original HTML as a template and the response as a source
// Assumes that both original and response have the exact same XML structure
func rebuildFromResponse(s_original string, s_response string) (bool, string) {
r_original := strings.NewReader(s_original)
d_original := xml.NewDecoder(r_original)
r_response := strings.NewReader(s_response)
d_response := xml.NewDecoder(r_response)
result := ""
// Configure the decoder for HTML; leave off strict and autoclose for XHTML
d_original.Strict = true
d_original.AutoClose = xml.HTMLAutoClose
d_original.Entity = xml.HTMLEntity
d_response.Strict = true
d_response.AutoClose = xml.HTMLAutoClose
d_response.Entity = xml.HTMLEntity
token_original, err_original := d_original.Token()
token_response, err_response := d_response.Token()
for {
// err_original can only be EOF or nil.
if err_original != nil || err_response != nil {
if err_original != err_response {
return false, ""
}
return true, result
}
switch v_original := token_original.(type) {
case xml.StartElement:
switch v_response := token_response.(type) {
case xml.StartElement:
if v_original.Name.Space != v_response.Name.Space || v_original.Name.Local != v_response.Name.Local {
return false, ""
}
result += "<" + attrName(v_original.Name)
for _, attr := range v_original.Attr {
if v_original.Name.Local != "img" || attr.Name.Local != "alt" {
result += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
}
}
for _, attr := range v_response.Attr {
if v_response.Name.Local == "img" && attr.Name.Local == "alt" {
result += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
}
}
if slices.Contains(xml.HTMLAutoClose, v_original.Name.Local) {
result += "/>"
} else {
result += ">"
}
case xml.CharData:
result += string(v_response)
token_response, err_response = d_response.Token()
continue
default:
return false, ""
}
case xml.EndElement:
switch v_response := token_response.(type) {
case xml.EndElement:
if v_original.Name.Space != v_response.Name.Space || v_original.Name.Local != v_response.Name.Local {
return false, ""
}
if !slices.Contains(xml.HTMLAutoClose, v_original.Name.Local) {
result += "" + attrName(v_original.Name) + ">"
}
case xml.CharData:
result += string(v_response)
token_response, err_response = d_response.Token()
continue
default:
return false, ""
}
case xml.CharData:
switch v_response := token_response.(type) {
case xml.CharData:
result += string(v_response)
case xml.StartElement:
result += string(v_original)
token_original, err_original = d_original.Token()
continue
case xml.EndElement:
result += string(v_original)
token_original, err_original = d_original.Token()
continue
default:
return false, ""
}
case xml.Comment:
result += ""
token_original, err_original = d_original.Token()
continue
case xml.ProcInst:
if len(v_original.Inst) == 0 {
result += "" + v_original.Target + "?>"
} else {
result += "" + v_original.Target + " " + string(v_original.Inst) + "?>"
}
token_original, err_original = d_original.Token()
continue
case xml.Directive:
result += ""
token_original, err_original = d_original.Token()
continue
}
token_original, err_original = d_original.Token()
token_response, err_response = d_response.Token()
}
}
func attrName(name xml.Name) string {
if name.Space == "" {
return name.Local
} else {
return name.Space + ":" + name.Local
}
}