Files
b2b/app/service/productDescriptionService/productDescriptionService.go
2026-03-16 08:51:35 +01:00

461 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 += "</" + keys[i] + ">\n"
}
request = cleanForPrompt(request)
// TranslateText is the standard Cloud Translation v3 endpoint.
// "parent" must be "projects/<PROJECT_ID>/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 += "</" + 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]
}
// 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 += "</" + 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 += "<!--" + string(v_original) + "-->"
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 += "<!" + string(v_original) + ">"
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
}
}