package routes import ( "bssapp-backend/models" "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "io/fs" "log" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/gorilla/mux" "github.com/lib/pq" ) var translationLangSet = map[string]struct{}{ "tr": {}, "en": {}, "de": {}, "it": {}, "es": {}, "ru": {}, "ar": {}, } var translationStatusSet = map[string]struct{}{ "pending": {}, "approved": {}, "rejected": {}, } var translationSourceTypeSet = map[string]struct{}{ "dummy": {}, "postgre": {}, "mssql": {}, } var ( reQuotedText = regexp.MustCompile(`['"]([^'"]{3,120})['"]`) reHasLetter = regexp.MustCompile(`[A-Za-zÇĞİÖŞÜçğıöşü]`) reBadText = regexp.MustCompile(`^(GET|POST|PUT|DELETE|OPTIONS|true|false|null|undefined)$`) reKeyUnsafe = regexp.MustCompile(`[^a-z0-9_]+`) ) type TranslationUpdatePayload struct { SourceTextTR *string `json:"source_text_tr"` TranslatedText *string `json:"translated_text"` SourceType *string `json:"source_type"` IsManual *bool `json:"is_manual"` Status *string `json:"status"` } type UpsertMissingPayload struct { Items []UpsertMissingItem `json:"items"` Languages []string `json:"languages"` } type UpsertMissingItem struct { TKey string `json:"t_key"` SourceTextTR string `json:"source_text_tr"` } type SyncSourcesPayload struct { AutoTranslate bool `json:"auto_translate"` Languages []string `json:"languages"` Limit int `json:"limit"` OnlyNew *bool `json:"only_new"` } type BulkApprovePayload struct { IDs []int64 `json:"ids"` } type BulkUpdatePayload struct { Items []BulkUpdateItem `json:"items"` } type TranslateSelectedPayload struct { TKeys []string `json:"t_keys"` Languages []string `json:"languages"` Limit int `json:"limit"` } type BulkUpdateItem struct { ID int64 `json:"id"` SourceTextTR *string `json:"source_text_tr"` TranslatedText *string `json:"translated_text"` SourceType *string `json:"source_type"` IsManual *bool `json:"is_manual"` Status *string `json:"status"` } type TranslationSyncOptions struct { AutoTranslate bool Languages []string Limit int OnlyNew bool TraceID string } type TranslationSyncResult struct { SeedCount int `json:"seed_count"` AffectedCount int `json:"affected_count"` AutoTranslated int `json:"auto_translated"` TargetLangs []string `json:"target_languages"` TraceID string `json:"trace_id"` DurationMS int64 `json:"duration_ms"` } type sourceSeed struct { TKey string SourceText string SourceType string } func GetTranslationRowsHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") q := strings.TrimSpace(r.URL.Query().Get("q")) lang := normalizeTranslationLang(r.URL.Query().Get("lang")) status := normalizeTranslationStatus(r.URL.Query().Get("status")) sourceType := normalizeTranslationSourceType(r.URL.Query().Get("source_type")) manualFilter := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("manual"))) missingOnly := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("missing"))) == "true" limit := 0 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 50000 { limit = parsed } } clauses := []string{"1=1"} args := make([]any, 0, 8) argIndex := 1 if q != "" { clauses = append(clauses, fmt.Sprintf("(source_text_tr ILIKE $%d OR translated_text ILIKE $%d)", argIndex, argIndex)) args = append(args, "%"+q+"%") argIndex++ } if lang != "" { clauses = append(clauses, fmt.Sprintf("lang_code = $%d", argIndex)) args = append(args, lang) argIndex++ } if status != "" { clauses = append(clauses, fmt.Sprintf("status = $%d", argIndex)) args = append(args, status) argIndex++ } if sourceType != "" { clauses = append(clauses, fmt.Sprintf("COALESCE(NULLIF(provider_meta->>'source_type',''),'dummy') = $%d", argIndex)) args = append(args, sourceType) argIndex++ } switch manualFilter { case "true": clauses = append(clauses, "is_manual = true") case "false": clauses = append(clauses, "is_manual = false") } if missingOnly { clauses = append(clauses, "(translated_text IS NULL OR btrim(translated_text) = '')") } query := fmt.Sprintf(` SELECT id, t_key, lang_code, COALESCE(NULLIF(provider_meta->>'source_type',''), 'dummy') AS source_type, source_text_tr, COALESCE(translated_text, '') AS translated_text, is_manual, status, COALESCE(provider, '') AS provider, updated_at FROM mk_translator WHERE %s ORDER BY t_key, lang_code `, strings.Join(clauses, " AND ")) if limit > 0 { query += fmt.Sprintf("LIMIT $%d", argIndex) args = append(args, limit) } rows, err := db.Query(query, args...) if err != nil { http.Error(w, "translation query error", http.StatusInternalServerError) return } defer rows.Close() list := make([]models.TranslatorRow, 0, 1024) for rows.Next() { var row models.TranslatorRow if err := rows.Scan( &row.ID, &row.TKey, &row.LangCode, &row.SourceType, &row.SourceTextTR, &row.TranslatedText, &row.IsManual, &row.Status, &row.Provider, &row.UpdatedAt, ); err != nil { http.Error(w, "translation scan error", http.StatusInternalServerError) return } list = append(list, row) } if err := rows.Err(); err != nil { http.Error(w, "translation rows error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "rows": list, "count": len(list), }) } } func UpdateTranslationRowHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") id, err := strconv.ParseInt(strings.TrimSpace(mux.Vars(r)["id"]), 10, 64) if err != nil || id <= 0 { http.Error(w, "invalid row id", http.StatusBadRequest) return } var payload TranslationUpdatePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } if payload.Status != nil { normalized := normalizeTranslationStatus(*payload.Status) if normalized == "" { http.Error(w, "invalid status", http.StatusBadRequest) return } payload.Status = &normalized } if payload.SourceType != nil { normalized := normalizeTranslationSourceType(*payload.SourceType) if normalized == "" { http.Error(w, "invalid source_type", http.StatusBadRequest) return } payload.SourceType = &normalized } updateQuery := ` UPDATE mk_translator SET source_text_tr = COALESCE($2, source_text_tr), translated_text = COALESCE($3, translated_text), is_manual = COALESCE($4, is_manual), status = COALESCE($5, status), provider_meta = CASE WHEN $6::text IS NULL THEN provider_meta ELSE jsonb_set(COALESCE(provider_meta, '{}'::jsonb), '{source_type}', to_jsonb($6::text), true) END, updated_at = NOW() WHERE id = $1 RETURNING id, t_key, lang_code, COALESCE(NULLIF(provider_meta->>'source_type',''), 'dummy') AS source_type, source_text_tr, COALESCE(translated_text, '') AS translated_text, is_manual, status, COALESCE(provider, '') AS provider, updated_at ` var row models.TranslatorRow err = db.QueryRow( updateQuery, id, nullableString(payload.SourceTextTR), nullableString(payload.TranslatedText), payload.IsManual, payload.Status, nullableString(payload.SourceType), ).Scan( &row.ID, &row.TKey, &row.LangCode, &row.SourceType, &row.SourceTextTR, &row.TranslatedText, &row.IsManual, &row.Status, &row.Provider, &row.UpdatedAt, ) if err == sql.ErrNoRows { http.Error(w, "translation row not found", http.StatusNotFound) return } if err != nil { http.Error(w, "translation update error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(row) } } func UpsertMissingTranslationsHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload UpsertMissingPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } items := normalizeMissingItems(payload.Items) if len(items) == 0 { http.Error(w, "items required", http.StatusBadRequest) return } languages := normalizeTargetLanguages(payload.Languages) affected, err := upsertMissingRows(db, items, languages, "dummy") if err != nil { http.Error(w, "upsert missing error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "items": len(items), "target_langs": languages, "affected_count": affected, }) } } func SyncTranslationSourcesHandler(pgDB *sql.DB, mssqlDB *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload SyncSourcesPayload _ = json.NewDecoder(r.Body).Decode(&payload) traceID := requestTraceID(r) w.Header().Set("X-Trace-ID", traceID) start := time.Now() onlyNew := payload.OnlyNew == nil || *payload.OnlyNew log.Printf( "[TranslationSync] trace=%s stage=request auto_translate=%t only_new=%t limit=%d langs=%v", traceID, payload.AutoTranslate, onlyNew, payload.Limit, payload.Languages, ) result, err := PerformTranslationSync(pgDB, mssqlDB, TranslationSyncOptions{ AutoTranslate: payload.AutoTranslate, Languages: payload.Languages, Limit: payload.Limit, OnlyNew: onlyNew, TraceID: traceID, }) if err != nil { log.Printf( "[TranslationSync] trace=%s stage=error duration_ms=%d err=%v", traceID, time.Since(start).Milliseconds(), err, ) http.Error(w, "translation source sync error", http.StatusInternalServerError) return } log.Printf( "[TranslationSync] trace=%s stage=response duration_ms=%d seeds=%d affected=%d auto_translated=%d", traceID, time.Since(start).Milliseconds(), result.SeedCount, result.AffectedCount, result.AutoTranslated, ) _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "trace_id": traceID, "result": result, "seed_count": result.SeedCount, "affected_count": result.AffectedCount, "auto_translated": result.AutoTranslated, "target_languages": result.TargetLangs, }) } } func TranslateSelectedTranslationsHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload TranslateSelectedPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } keys := normalizeStringList(payload.TKeys, 5000) if len(keys) == 0 { http.Error(w, "t_keys required", http.StatusBadRequest) return } targetLangs := normalizeTargetLanguages(payload.Languages) limit := payload.Limit if limit <= 0 { limit = len(keys) * len(targetLangs) } if limit <= 0 { limit = 1000 } if limit > 50000 { limit = 50000 } traceID := requestTraceID(r) w.Header().Set("X-Trace-ID", traceID) start := time.Now() log.Printf( "[TranslationSelected] trace=%s stage=request keys=%d limit=%d langs=%v", traceID, len(keys), limit, targetLangs, ) translatedCount, err := autoTranslatePendingRowsForKeys(db, targetLangs, limit, keys, traceID) if err != nil { log.Printf( "[TranslationSelected] trace=%s stage=error duration_ms=%d err=%v", traceID, time.Since(start).Milliseconds(), err, ) http.Error(w, "translate selected error", http.StatusInternalServerError) return } log.Printf( "[TranslationSelected] trace=%s stage=done duration_ms=%d translated=%d", traceID, time.Since(start).Milliseconds(), translatedCount, ) _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "trace_id": traceID, "translated_count": translatedCount, "key_count": len(keys), "target_languages": targetLangs, "duration_ms": time.Since(start).Milliseconds(), }) } } func BulkApproveTranslationsHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload BulkApprovePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } ids := normalizeIDListInt64(payload.IDs) if len(ids) == 0 { http.Error(w, "ids required", http.StatusBadRequest) return } res, err := db.Exec(` UPDATE mk_translator SET status = 'approved', is_manual = true, updated_at = NOW(), provider_meta = jsonb_set(COALESCE(provider_meta, '{}'::jsonb), '{is_new}', 'false'::jsonb, true) WHERE id = ANY($1) `, pq.Array(ids)) if err != nil { http.Error(w, "bulk approve error", http.StatusInternalServerError) return } affected, _ := res.RowsAffected() _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "affected_count": affected, }) } } func BulkUpdateTranslationsHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload BulkUpdatePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } if len(payload.Items) == 0 { http.Error(w, "items required", http.StatusBadRequest) return } tx, err := db.Begin() if err != nil { http.Error(w, "transaction start error", http.StatusInternalServerError) return } defer tx.Rollback() affected := 0 for _, it := range payload.Items { if it.ID <= 0 { continue } status := normalizeOptionalStatus(it.Status) sourceType := normalizeOptionalSourceType(it.SourceType) res, err := tx.Exec(` UPDATE mk_translator SET source_text_tr = COALESCE($2, source_text_tr), translated_text = COALESCE($3, translated_text), is_manual = COALESCE($4, is_manual), status = COALESCE($5, status), provider_meta = CASE WHEN $6::text IS NULL THEN provider_meta ELSE jsonb_set(COALESCE(provider_meta, '{}'::jsonb), '{source_type}', to_jsonb($6::text), true) END, updated_at = NOW() WHERE id = $1 `, it.ID, nullableString(it.SourceTextTR), nullableString(it.TranslatedText), it.IsManual, status, sourceType) if err != nil { http.Error(w, "bulk update error", http.StatusInternalServerError) return } if n, _ := res.RowsAffected(); n > 0 { affected += int(n) } } if err := tx.Commit(); err != nil { http.Error(w, "transaction commit error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "affected_count": affected, }) } } func PerformTranslationSync(pgDB *sql.DB, mssqlDB *sql.DB, options TranslationSyncOptions) (TranslationSyncResult, error) { traceID := strings.TrimSpace(options.TraceID) if traceID == "" { traceID = "trsync-" + strconv.FormatInt(time.Now().UnixNano(), 36) } start := time.Now() limit := options.Limit if limit <= 0 || limit > 100000 { limit = 20000 } targetLangs := normalizeTargetLanguages(options.Languages) log.Printf( "[TranslationSync] trace=%s stage=start auto_translate=%t only_new=%t limit=%d langs=%v", traceID, options.AutoTranslate, options.OnlyNew, limit, targetLangs, ) collectStart := time.Now() seeds := collectSourceSeeds(pgDB, mssqlDB, limit) seeds, reusedByText := reuseExistingSeedKeys(pgDB, seeds) log.Printf( "[TranslationSync] trace=%s stage=collect done_ms=%d total=%d reused_by_text=%d sources=%s", traceID, time.Since(collectStart).Milliseconds(), len(seeds), reusedByText, formatSourceCounts(countSeedsBySource(seeds)), ) if options.OnlyNew { before := len(seeds) filterStart := time.Now() seeds = filterNewSeeds(pgDB, seeds) log.Printf( "[TranslationSync] trace=%s stage=filter_only_new done_ms=%d before=%d after=%d skipped=%d", traceID, time.Since(filterStart).Milliseconds(), before, len(seeds), before-len(seeds), ) } if len(seeds) == 0 { return TranslationSyncResult{ TargetLangs: targetLangs, TraceID: traceID, DurationMS: time.Since(start).Milliseconds(), }, nil } upsertStart := time.Now() affected, err := upsertSourceSeeds(pgDB, seeds, targetLangs) if err != nil { return TranslationSyncResult{}, err } log.Printf( "[TranslationSync] trace=%s stage=upsert done_ms=%d affected=%d", traceID, time.Since(upsertStart).Milliseconds(), affected, ) autoTranslated := 0 if options.AutoTranslate { autoStart := time.Now() var autoErr error autoTranslated, autoErr = autoTranslatePendingRowsForKeys(pgDB, targetLangs, limit, uniqueSeedKeys(seeds), traceID) if autoErr != nil { log.Printf( "[TranslationSync] trace=%s stage=auto_translate done_ms=%d translated=%d err=%v", traceID, time.Since(autoStart).Milliseconds(), autoTranslated, autoErr, ) } else { log.Printf( "[TranslationSync] trace=%s stage=auto_translate done_ms=%d translated=%d", traceID, time.Since(autoStart).Milliseconds(), autoTranslated, ) } } result := TranslationSyncResult{ SeedCount: len(seeds), AffectedCount: affected, AutoTranslated: autoTranslated, TargetLangs: targetLangs, TraceID: traceID, DurationMS: time.Since(start).Milliseconds(), } log.Printf( "[TranslationSync] trace=%s stage=done duration_ms=%d seeds=%d affected=%d auto_translated=%d", traceID, result.DurationMS, result.SeedCount, result.AffectedCount, result.AutoTranslated, ) return result, nil } func upsertMissingRows(db *sql.DB, items []UpsertMissingItem, languages []string, forcedSourceType string) (int, error) { tx, err := db.Begin() if err != nil { return 0, err } defer tx.Rollback() affected := 0 for _, it := range items { sourceType := forcedSourceType if sourceType == "" { sourceType = "dummy" } res, err := tx.Exec(` INSERT INTO mk_translator (t_key, lang_code, source_text_tr, translated_text, is_manual, status, provider, provider_meta) VALUES ($1, 'tr', $2, $2, false, 'approved', 'seed', jsonb_build_object('source_type', $3::text)) ON CONFLICT (t_key, lang_code) DO UPDATE SET source_text_tr = EXCLUDED.source_text_tr, provider_meta = jsonb_set(COALESCE(mk_translator.provider_meta, '{}'::jsonb), '{source_type}', to_jsonb($3::text), true), updated_at = NOW() `, it.TKey, it.SourceTextTR, sourceType) if err != nil { return 0, err } if n, _ := res.RowsAffected(); n > 0 { affected += int(n) } for _, lang := range languages { res, err := tx.Exec(` INSERT INTO mk_translator (t_key, lang_code, source_text_tr, translated_text, is_manual, status, provider, provider_meta) VALUES ($1, $2, $3, NULL, false, 'pending', NULL, jsonb_build_object('source_type', $4::text)) ON CONFLICT (t_key, lang_code) DO UPDATE SET source_text_tr = EXCLUDED.source_text_tr, provider_meta = jsonb_set(COALESCE(mk_translator.provider_meta, '{}'::jsonb), '{source_type}', to_jsonb($4::text), true), updated_at = NOW() `, it.TKey, lang, it.SourceTextTR, sourceType) if err != nil { return 0, err } if n, _ := res.RowsAffected(); n > 0 { affected += int(n) } } } if err := tx.Commit(); err != nil { return 0, err } return affected, nil } func upsertSourceSeeds(db *sql.DB, seeds []sourceSeed, languages []string) (int, error) { tx, err := db.Begin() if err != nil { return 0, err } defer tx.Rollback() affected := 0 for _, seed := range seeds { if seed.TKey == "" || seed.SourceText == "" { continue } sourceType := normalizeTranslationSourceType(seed.SourceType) if sourceType == "" { sourceType = "dummy" } res, err := tx.Exec(` INSERT INTO mk_translator (t_key, lang_code, source_text_tr, translated_text, is_manual, status, provider, provider_meta) VALUES ($1, 'tr', $2, $2, false, 'approved', 'seed', jsonb_build_object('source_type', $3::text, 'is_new', false)) ON CONFLICT (t_key, lang_code) DO UPDATE SET source_text_tr = EXCLUDED.source_text_tr, provider_meta = jsonb_set( COALESCE(mk_translator.provider_meta, '{}'::jsonb), '{source_type}', to_jsonb(COALESCE(NULLIF(mk_translator.provider_meta->>'source_type', ''), $3::text)), true ), updated_at = NOW() `, seed.TKey, seed.SourceText, sourceType) if err != nil { return 0, err } if n, _ := res.RowsAffected(); n > 0 { affected += int(n) } for _, lang := range languages { res, err := tx.Exec(` INSERT INTO mk_translator (t_key, lang_code, source_text_tr, translated_text, is_manual, status, provider, provider_meta) VALUES ($1, $2, $3, NULL, false, 'pending', NULL, jsonb_build_object('source_type', $4::text, 'is_new', true)) ON CONFLICT (t_key, lang_code) DO UPDATE SET source_text_tr = EXCLUDED.source_text_tr, provider_meta = jsonb_set( COALESCE(mk_translator.provider_meta, '{}'::jsonb), '{source_type}', to_jsonb(COALESCE(NULLIF(mk_translator.provider_meta->>'source_type', ''), $4::text)), true ), updated_at = NOW() `, seed.TKey, lang, seed.SourceText, sourceType) if err != nil { return 0, err } if n, _ := res.RowsAffected(); n > 0 { affected += int(n) } } } if err := tx.Commit(); err != nil { return 0, err } return affected, nil } func collectSourceSeeds(pgDB *sql.DB, mssqlDB *sql.DB, limit int) []sourceSeed { seen := map[string]struct{}{} out := make([]sourceSeed, 0, limit) appendSeed := func(seed sourceSeed) { if seed.TKey == "" || seed.SourceText == "" || seed.SourceType == "" { return } key := normalizeSeedTextKey(seed.SourceText) if _, ok := seen[key]; ok { return } seen[key] = struct{}{} out = append(out, seed) } for _, row := range collectPostgreSeeds(pgDB, limit) { appendSeed(row) if len(out) >= limit { return out } } for _, row := range collectMSSQLSeeds(mssqlDB, limit-len(out)) { appendSeed(row) if len(out) >= limit { return out } } for _, row := range collectDummySeeds(limit - len(out)) { appendSeed(row) if len(out) >= limit { return out } } return out } func collectPostgreSeeds(pgDB *sql.DB, limit int) []sourceSeed { if pgDB == nil || limit <= 0 { return nil } rows, err := pgDB.Query(` SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position LIMIT $1 `, limit) if err != nil { return nil } defer rows.Close() out := make([]sourceSeed, 0, limit) for rows.Next() && len(out) < limit { var tableName, columnName string if err := rows.Scan(&tableName, &columnName); err != nil { continue } text := normalizeDisplayText(columnName) key := makeTextBasedSeedKey(text) out = append(out, sourceSeed{ TKey: key, SourceText: text, SourceType: "postgre", }) } return out } func collectMSSQLSeeds(mssqlDB *sql.DB, limit int) []sourceSeed { if mssqlDB == nil || limit <= 0 { return nil } maxPerRun := parsePositiveIntEnv("TRANSLATION_MSSQL_SEED_LIMIT", 2500) if limit > maxPerRun { limit = maxPerRun } timeoutSec := parsePositiveIntEnv("TRANSLATION_MSSQL_SCHEMA_TIMEOUT_SEC", 20) query := fmt.Sprintf(` SELECT TOP (%d) TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS ORDER BY TABLE_NAME, ORDINAL_POSITION `, limit) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) defer cancel() rows, err := mssqlDB.QueryContext(ctx, query) if err != nil { log.Printf("[TranslationSync] stage=collect_mssql skipped err=%v", err) return nil } defer rows.Close() out := make([]sourceSeed, 0, limit) for rows.Next() && len(out) < limit { var tableName, columnName string if err := rows.Scan(&tableName, &columnName); err != nil { continue } text := normalizeDisplayText(columnName) key := makeTextBasedSeedKey(text) out = append(out, sourceSeed{ TKey: key, SourceText: text, SourceType: "mssql", }) } return out } func collectDummySeeds(limit int) []sourceSeed { if limit <= 0 { return nil } root := detectProjectRoot() if root == "" { return nil } uiRoot := filepath.Join(root, "ui", "src") if _, err := os.Stat(uiRoot); err != nil { return nil } out := make([]sourceSeed, 0, limit) seen := make(map[string]struct{}, limit) _ = filepath.WalkDir(uiRoot, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(path)) if ext != ".vue" && ext != ".js" && ext != ".ts" { return nil } b, err := os.ReadFile(path) if err != nil { return nil } matches := reQuotedText.FindAllStringSubmatch(string(b), -1) for _, m := range matches { text := strings.TrimSpace(m[1]) if !isCandidateText(text) { continue } if _, ok := seen[text]; ok { continue } seen[text] = struct{}{} key := makeTextBasedSeedKey(text) out = append(out, sourceSeed{ TKey: key, SourceText: text, SourceType: "dummy", }) if len(out) >= limit { return errors.New("limit reached") } } return nil }) return out } func autoTranslatePendingRows(db *sql.DB, langs []string, limit int) (int, error) { return autoTranslatePendingRowsForKeys(db, langs, limit, nil, "") } func autoTranslatePendingRowsForKeys(db *sql.DB, langs []string, limit int, keys []string, traceID string) (int, error) { traceID = strings.TrimSpace(traceID) if traceID == "" { traceID = "trauto-" + strconv.FormatInt(time.Now().UnixNano(), 36) } if len(keys) == 0 { log.Printf("[TranslationAuto] trace=%s stage=skip reason=no_keys", traceID) return 0, nil } start := time.Now() rows, err := db.Query(` SELECT id, lang_code, source_text_tr FROM mk_translator WHERE lang_code = ANY($1) AND t_key = ANY($3) AND (translated_text IS NULL OR btrim(translated_text) = '') AND is_manual = false ORDER BY updated_at ASC LIMIT $2 `, pqArray(langs), limit, pq.Array(keys)) if err != nil { return 0, err } defer rows.Close() type pending struct { ID int64 Lang string Text string } list := make([]pending, 0, limit) pendingByLang := map[string]int{} sourceChars := 0 for rows.Next() { var p pending if err := rows.Scan(&p.ID, &p.Lang, &p.Text); err != nil { continue } if strings.TrimSpace(p.Text) == "" { continue } p.Lang = normalizeTranslationLang(p.Lang) if p.Lang == "" { continue } list = append(list, p) pendingByLang[p.Lang]++ sourceChars += len([]rune(strings.TrimSpace(p.Text))) } if err := rows.Err(); err != nil { return 0, err } log.Printf( "[TranslationAuto] trace=%s stage=prepare candidates=%d limit=%d keys=%d langs=%v source_chars=%d pending_by_lang=%s", traceID, len(list), limit, len(keys), langs, sourceChars, formatLangCounts(pendingByLang), ) if len(list) == 0 { log.Printf( "[TranslationAuto] trace=%s stage=done duration_ms=%d translated=0 failed_translate=0 failed_update=0 rps=0.00", traceID, time.Since(start).Milliseconds(), ) return 0, nil } done := 0 failedTranslate := 0 failedUpdate := 0 doneByLang := map[string]int{} progressEvery := parsePositiveIntEnv("TRANSLATION_AUTO_PROGRESS_EVERY", 100) if progressEvery <= 0 { progressEvery = 100 } progressSec := parsePositiveIntEnv("TRANSLATION_AUTO_PROGRESS_SEC", 15) if progressSec <= 0 { progressSec = 15 } progressTicker := time.Duration(progressSec) * time.Second lastProgress := time.Now() for i, p := range list { tr, err := callAzureTranslate(p.Text, p.Lang) if err != nil || strings.TrimSpace(tr) == "" { failedTranslate++ continue } _, err = db.Exec(` UPDATE mk_translator SET translated_text = $2, status = 'pending', is_manual = false, provider = 'azure_translator', updated_at = NOW() WHERE id = $1 `, p.ID, strings.TrimSpace(tr)) if err != nil { failedUpdate++ continue } done++ doneByLang[p.Lang]++ processed := i + 1 shouldLogProgress := processed%progressEvery == 0 || time.Since(lastProgress) >= progressTicker || processed == len(list) if shouldLogProgress { elapsed := time.Since(start) rps := float64(done) if elapsed > 0 { rps = float64(done) / elapsed.Seconds() } log.Printf( "[TranslationAuto] trace=%s stage=progress processed=%d/%d translated=%d failed_translate=%d failed_update=%d elapsed_ms=%d rps=%.2f done_by_lang=%s", traceID, processed, len(list), done, failedTranslate, failedUpdate, elapsed.Milliseconds(), rps, formatLangCounts(doneByLang), ) lastProgress = time.Now() } } elapsed := time.Since(start) rps := float64(done) if elapsed > 0 { rps = float64(done) / elapsed.Seconds() } log.Printf( "[TranslationAuto] trace=%s stage=done duration_ms=%d candidates=%d translated=%d failed_translate=%d failed_update=%d rps=%.2f done_by_lang=%s", traceID, elapsed.Milliseconds(), len(list), done, failedTranslate, failedUpdate, rps, formatLangCounts(doneByLang), ) return done, nil } func formatLangCounts(counts map[string]int) string { if len(counts) == 0 { return "-" } keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k])) } return strings.Join(parts, ",") } func filterNewSeeds(pgDB *sql.DB, seeds []sourceSeed) []sourceSeed { if pgDB == nil || len(seeds) == 0 { return seeds } keys := uniqueSeedKeys(seeds) if len(keys) == 0 { return nil } textKeys := uniqueSeedTextKeys(seeds) rows, err := pgDB.Query(` SELECT DISTINCT t_key, lower(btrim(source_text_tr)) AS text_key FROM mk_translator WHERE t_key = ANY($1) OR lower(btrim(source_text_tr)) = ANY($2) `, pq.Array(keys), pq.Array(textKeys)) if err != nil { return seeds } defer rows.Close() existing := make(map[string]struct{}, len(keys)) existingText := make(map[string]struct{}, len(textKeys)) for rows.Next() { var key string var textKey sql.NullString if err := rows.Scan(&key, &textKey); err == nil { if strings.TrimSpace(key) != "" { existing[key] = struct{}{} } if textKey.Valid { t := strings.TrimSpace(textKey.String) if t != "" { existingText[t] = struct{}{} } } } } out := make([]sourceSeed, 0, len(seeds)) for _, seed := range seeds { if _, ok := existing[seed.TKey]; ok { continue } if _, ok := existingText[normalizeSeedTextKey(seed.SourceText)]; ok { continue } out = append(out, seed) } return out } func uniqueSeedKeys(seeds []sourceSeed) []string { seen := make(map[string]struct{}, len(seeds)) out := make([]string, 0, len(seeds)) for _, seed := range seeds { if seed.TKey == "" { continue } if _, ok := seen[seed.TKey]; ok { continue } seen[seed.TKey] = struct{}{} out = append(out, seed.TKey) } return out } func uniqueSeedTextKeys(seeds []sourceSeed) []string { seen := make(map[string]struct{}, len(seeds)) out := make([]string, 0, len(seeds)) for _, seed := range seeds { k := normalizeSeedTextKey(seed.SourceText) if k == "" { continue } if _, ok := seen[k]; ok { continue } seen[k] = struct{}{} out = append(out, k) } return out } func reuseExistingSeedKeys(pgDB *sql.DB, seeds []sourceSeed) ([]sourceSeed, int) { if pgDB == nil || len(seeds) == 0 { return seeds, 0 } textKeys := uniqueSeedTextKeys(seeds) if len(textKeys) == 0 { return seeds, 0 } rows, err := pgDB.Query(` SELECT x.text_key, x.t_key FROM ( SELECT lower(btrim(source_text_tr)) AS text_key, t_key, ROW_NUMBER() OVER ( PARTITION BY lower(btrim(source_text_tr)) ORDER BY id ASC ) AS rn FROM mk_translator WHERE lower(btrim(source_text_tr)) = ANY($1) ) x WHERE x.rn = 1 `, pq.Array(textKeys)) if err != nil { return seeds, 0 } defer rows.Close() existingByText := make(map[string]string, len(textKeys)) for rows.Next() { var textKey, tKey string if err := rows.Scan(&textKey, &tKey); err != nil { continue } textKey = strings.TrimSpace(strings.ToLower(textKey)) tKey = strings.TrimSpace(tKey) if textKey == "" || tKey == "" { continue } existingByText[textKey] = tKey } reused := 0 for i := range seeds { textKey := normalizeSeedTextKey(seeds[i].SourceText) if textKey == "" { continue } if existingKey, ok := existingByText[textKey]; ok && existingKey != "" && seeds[i].TKey != existingKey { seeds[i].TKey = existingKey reused++ } } return seeds, reused } func countSeedsBySource(seeds []sourceSeed) map[string]int { out := map[string]int{ "dummy": 0, "postgre": 0, "mssql": 0, } for _, s := range seeds { key := normalizeTranslationSourceType(s.SourceType) if key == "" { key = "dummy" } out[key]++ } return out } func formatSourceCounts(counts map[string]int) string { return fmt.Sprintf("dummy=%d postgre=%d mssql=%d", counts["dummy"], counts["postgre"], counts["mssql"]) } func requestTraceID(r *http.Request) string { if r == nil { return "trsync-" + strconv.FormatInt(time.Now().UnixNano(), 36) } id := strings.TrimSpace(r.Header.Get("X-Request-ID")) if id == "" { id = strings.TrimSpace(r.Header.Get("X-Correlation-ID")) } if id == "" { id = "trsync-" + strconv.FormatInt(time.Now().UnixNano(), 36) } return id } func callAzureTranslate(sourceText, targetLang string) (string, error) { key := strings.TrimSpace(os.Getenv("AZURE_TRANSLATOR_KEY")) endpoint := strings.TrimSpace(os.Getenv("AZURE_TRANSLATOR_ENDPOINT")) region := strings.TrimSpace(os.Getenv("AZURE_TRANSLATOR_REGION")) if key == "" { return "", errors.New("AZURE_TRANSLATOR_KEY not set") } if endpoint == "" { return "", errors.New("AZURE_TRANSLATOR_ENDPOINT not set") } if region == "" { return "", errors.New("AZURE_TRANSLATOR_REGION not set") } sourceLang := strings.TrimSpace(strings.ToLower(os.Getenv("TRANSLATION_SOURCE_LANG"))) if sourceLang == "" { sourceLang = "tr" } targetLang = normalizeTranslationLang(targetLang) if targetLang == "" || targetLang == "tr" { return "", fmt.Errorf("invalid target language: %q", targetLang) } endpoint = strings.TrimRight(endpoint, "/") normalizedEndpoint := strings.ToLower(endpoint) translatePath := "/translate" // Azure custom endpoint requires the translator path with version in URL. if strings.Contains(normalizedEndpoint, ".cognitiveservices.azure.com") { translatePath = "/translator/text/v3.0/translate" } baseURL, err := url.Parse(endpoint + translatePath) if err != nil { return "", fmt.Errorf("invalid AZURE_TRANSLATOR_ENDPOINT: %w", err) } q := baseURL.Query() if translatePath == "/translate" { q.Set("api-version", "3.0") } q.Set("from", sourceLang) q.Set("to", targetLang) baseURL.RawQuery = q.Encode() payload := []map[string]string{ {"text": sourceText}, } body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, baseURL.String(), bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Ocp-Apim-Subscription-Key", key) req.Header.Set("Ocp-Apim-Subscription-Region", region) req.Header.Set("Content-Type", "application/json; charset=UTF-8") timeoutSec := parsePositiveIntEnv("TRANSLATION_HTTP_TIMEOUT_SEC", 60) client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return "", fmt.Errorf("azure translator status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw))) } var result []struct { Translations []struct { Text string `json:"text"` To string `json:"to"` } `json:"translations"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } if len(result) == 0 || len(result[0].Translations) == 0 { return "", errors.New("azure translator empty response") } return strings.TrimSpace(result[0].Translations[0].Text), nil } func nullableString(v *string) any { if v == nil { return nil } s := strings.TrimSpace(*v) return s } func normalizeTranslationLang(v string) string { lang := strings.ToLower(strings.TrimSpace(v)) if _, ok := translationLangSet[lang]; ok { return lang } return "" } func normalizeTranslationStatus(v string) string { status := strings.ToLower(strings.TrimSpace(v)) if _, ok := translationStatusSet[status]; ok { return status } return "" } func normalizeTranslationSourceType(v string) string { sourceType := strings.ToLower(strings.TrimSpace(v)) if _, ok := translationSourceTypeSet[sourceType]; ok { return sourceType } return "" } func normalizeTargetLanguages(list []string) []string { if len(list) == 0 { return []string{"en", "de", "it", "es", "ru", "ar"} } seen := make(map[string]struct{}, len(list)) out := make([]string, 0, len(list)) for _, v := range list { lang := normalizeTranslationLang(v) if lang == "" || lang == "tr" { continue } if _, ok := seen[lang]; ok { continue } seen[lang] = struct{}{} out = append(out, lang) } if len(out) == 0 { return []string{"en", "de", "it", "es", "ru", "ar"} } return out } func normalizeOptionalStatus(v *string) any { if v == nil { return nil } s := normalizeTranslationStatus(*v) if s == "" { return nil } return s } func normalizeOptionalSourceType(v *string) any { if v == nil { return nil } s := normalizeTranslationSourceType(*v) if s == "" { return nil } return s } func normalizeMissingItems(items []UpsertMissingItem) []UpsertMissingItem { seen := make(map[string]struct{}, len(items)) out := make([]UpsertMissingItem, 0, len(items)) for _, it := range items { key := strings.TrimSpace(it.TKey) source := strings.TrimSpace(it.SourceTextTR) if key == "" || source == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, UpsertMissingItem{ TKey: key, SourceTextTR: source, }) } return out } func normalizeIDListInt64(ids []int64) []int64 { seen := make(map[int64]struct{}, len(ids)) out := make([]int64, 0, len(ids)) for _, id := range ids { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func detectProjectRoot() string { wd, err := os.Getwd() if err != nil { return "" } candidates := []string{ wd, filepath.Dir(wd), filepath.Dir(filepath.Dir(wd)), } for _, c := range candidates { if _, err := os.Stat(filepath.Join(c, "ui")); err == nil { return c } } return "" } func isCandidateText(s string) bool { s = strings.TrimSpace(s) if len(s) < 3 || len(s) > 120 { return false } if reBadText.MatchString(s) { return false } if !reHasLetter.MatchString(s) { return false } if strings.Contains(s, "/api/") { return false } return true } func sanitizeKey(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, " ", "_") s = reKeyUnsafe.ReplaceAllString(s, "_") s = strings.Trim(s, "_") if s == "" { return "x" } return s } func normalizeDisplayText(s string) string { s = strings.TrimSpace(strings.ReplaceAll(s, "_", " ")) s = strings.Join(strings.Fields(s), " ") if s == "" { return "" } return s } func hashKey(s string) string { base := sanitizeKey(s) if len(base) > 40 { base = base[:40] } sum := 0 for _, r := range s { sum += int(r) } return fmt.Sprintf("%s_%d", base, sum%1000000) } func makeTextBasedSeedKey(sourceText string) string { return "txt." + hashKey(normalizeSeedTextKey(sourceText)) } func normalizeSeedTextKey(s string) string { return strings.ToLower(strings.TrimSpace(normalizeDisplayText(s))) } func pqArray(values []string) any { if len(values) == 0 { return pq.Array([]string{}) } out := make([]string, 0, len(values)) for _, v := range values { out = append(out, strings.TrimSpace(v)) } sort.Strings(out) return pq.Array(out) } func parsePositiveIntEnv(name string, fallback int) int { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return fallback } n, err := strconv.Atoi(raw) if err != nil || n <= 0 { return fallback } return n } func normalizeStringList(items []string, max int) []string { if len(items) == 0 { return nil } if max <= 0 { max = len(items) } out := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, raw := range items { v := strings.TrimSpace(raw) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) if len(out) >= max { break } } return out }