1670 lines
40 KiB
Go
1670 lines
40 KiB
Go
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, "/")
|
||
baseURL, err := url.Parse(endpoint + "/translate")
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid AZURE_TRANSLATOR_ENDPOINT: %w", err)
|
||
}
|
||
q := baseURL.Query()
|
||
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
|
||
}
|