package productDescriptionService import ( "context" "encoding/xml" "fmt" "io" "log" "os" "slices" "strings" "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/langsService" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "google.golang.org/api/option" "gorm.io/gorm" // [START translate_v3_import_client_library] "cloud.google.com/go/auth/credentials" translate "cloud.google.com/go/translate/apiv3" "cloud.google.com/go/translate/apiv3/translatepb" // [END translate_v3_import_client_library] ) type ProductDescriptionService struct { db *gorm.DB ctx context.Context googleCli translate.TranslationClient // projectID is the Google Cloud project ID used as the "parent" in API calls, // e.g. "projects/my-project-123/locations/global" projectID string } // New creates a ProductDescriptionService 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() *ProductDescriptionService { 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("productDescriptionService: 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("productDescriptionService: cannot build Google credentials: %v", err) } googleCli, err := translate.NewTranslationClient(ctx, option.WithAuthCredentials(creds)) if err != nil { log.Fatalf("productDescriptionService: cannot create Translation client: %v", err) } return &ProductDescriptionService{ db: db.Get(), ctx: ctx, googleCli: *googleCli, projectID: cfg.GoogleTranslate.ProjectID, } } // We assume that any user has access to all product descriptions func (s *ProductDescriptionService) GetProductDescription(userID uint, productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error) { var ProductDescription model.ProductDescription err := s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID). First(&ProductDescription).Error if err != nil { return nil, fmt.Errorf("database error: %w", err) } return &ProductDescription, nil } // Updates relevant fields with the "updates" map func (s *ProductDescriptionService) SaveProductDescription(userID uint, productID uint, productShopID 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 text, exists := updates[mustBeHTML[i]]; exists { if !isValidXHTML(text) { return responseErrors.ErrInvalidXHTML } } } record := model.ProductDescription{ ProductID: productID, ShopID: productShopID, LangID: productLangID, } err := s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID). FirstOrCreate(&record).Error if err != nil { return fmt.Errorf("database error: %w", err) } if len(updates) == 0 { return nil } updatesIface := make(map[string]interface{}, len(updates)) for k, v := range updates { updatesIface[k] = v } err = s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID). Updates(updatesIface).Error if err != nil { return fmt.Errorf("database error: %w", err) } return nil } // 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 *ProductDescriptionService) TranslateProductDescription(userID uint, productID uint, productShopID uint, productFromLangID uint, productToLangID uint) (*model.ProductDescription, error) { var ProductDescription model.ProductDescription err := s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productFromLangID). First(&ProductDescription).Error if err != nil { return nil, fmt.Errorf("database error: %w", 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 := "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 += "\n" } request = cleanForPrompt(request) // TranslateText is the standard Cloud Translation v3 endpoint. // "parent" must be "projects//locations/global" (or a specific region). // MimeType "text/plain" is used because cleanForPrompt strips HTML attributes; // switch to "text/html" if you want Google to preserve HTML tags automatically. req := &translatepb.TranslateTextRequest{ Parent: fmt.Sprintf("projects/%s/locations/global", s.projectID), TargetLanguageCode: lang.ISOCode, MimeType: "text/plain", Contents: []string{request}, } responseGoogle, err := s.googleCli.TranslateText(s.ctx, req) if err != nil { fmt.Println(err) return nil, err } // TranslateText returns one Translation per input string. if len(responseGoogle.GetTranslations()) == 0 { return nil, responseErrors.ErrOpenAIBadOutput } response := responseGoogle.GetTranslations()[0].GetTranslatedText() for i := 0; i < len(keys); i++ { success, resolution := resolveResponse(*fields[i], response, keys[i]) if !success { return nil, responseErrors.ErrOpenAIBadOutput } *fields[i] = resolution fmt.Println("resolution: ", resolution) } return &ProductDescription, nil } // 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 } } } 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) } } prompt += ">" case xml.EndElement: prompt += "" 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+">", "") if !success || !isValidXHTML(match) { return false, "" } success, resolution := RebuildFromResponse("<"+key+">"+original+"", "<"+key+">"+match+"") 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] } // 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) } } 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 v_original.Name.Local != "img" { result += "" } 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 += "" } else { result += "" } 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 } }