2030 lines
50 KiB
Go
2030 lines
50 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"
|
|
"sync/atomic"
|
|
"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 (
|
|
reHasLetter = regexp.MustCompile(`[A-Za-zÇĞİÖŞÜçğıöşü]`)
|
|
reBadText = regexp.MustCompile(`^(GET|POST|PUT|DELETE|OPTIONS|true|false|null|undefined)$`)
|
|
reKeyUnsafe = regexp.MustCompile(`[^a-z0-9_]+`)
|
|
reVueTemplate = regexp.MustCompile(`(?is)<template[^>]*>(.*?)</template>`)
|
|
reVueScript = regexp.MustCompile(`(?is)<script[^>]*>(.*?)</script>`)
|
|
reTemplateAttr = regexp.MustCompile(`\b(?:label|title|placeholder|aria-label|hint)\s*=\s*['"]([^'"]{2,180})['"]`)
|
|
reTemplateText = regexp.MustCompile(`>([^<]{3,180})<`)
|
|
reScriptLabelProp = regexp.MustCompile(`\blabel\s*:\s*['"]([^'"]{2,180})['"]`)
|
|
reScriptUIProp = regexp.MustCompile(`\b(?:label|message|title|placeholder|hint)\s*:\s*['"]([^'"]{2,180})['"]`)
|
|
reTemplateDynamic = regexp.MustCompile(`[{][{]|[}][}]`)
|
|
reCodeLikeText = regexp.MustCompile(`(?i)(\bconst\b|\blet\b|\bvar\b|\breturn\b|\bfunction\b|=>|\|\||&&|\?\?|//|/\*|\*/|\.trim\(|\.replace\(|\.map\(|\.filter\()`)
|
|
)
|
|
|
|
var translationNoiseTokens = map[string]struct{}{
|
|
"flat": {},
|
|
"dense": {},
|
|
"filled": {},
|
|
"outlined": {},
|
|
"borderless": {},
|
|
"clearable": {},
|
|
"loading": {},
|
|
"disable": {},
|
|
"readonly": {},
|
|
"hide-bottom": {},
|
|
"stack-label": {},
|
|
"emit-value": {},
|
|
"map-options": {},
|
|
"use-input": {},
|
|
"multiple": {},
|
|
"options": {},
|
|
"rows": {},
|
|
"cols": {},
|
|
"class": {},
|
|
"style": {},
|
|
}
|
|
|
|
var translationDummyAllowedVueDirs = []string{
|
|
"pages/",
|
|
"components/",
|
|
"layouts/",
|
|
}
|
|
|
|
var translationDummyAllowedStoreDirs = []string{
|
|
"stores/",
|
|
}
|
|
|
|
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 RuntimeTranslationsResponse struct {
|
|
Lang string `json:"lang"`
|
|
ByKey map[string]string `json:"by_key"`
|
|
ByText map[string]string `json:"by_text"`
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
offset := 0
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 1000000 {
|
|
offset = 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)
|
|
argIndex++
|
|
}
|
|
if offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET $%d", argIndex)
|
|
args = append(args, offset)
|
|
}
|
|
|
|
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 GetRuntimeTranslationsHandler(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
lang := normalizeRuntimeTranslationLang(firstNonEmpty(
|
|
r.URL.Query().Get("lang"),
|
|
r.Header.Get("Accept-Language"),
|
|
))
|
|
if lang == "" {
|
|
lang = "tr"
|
|
}
|
|
|
|
resp := RuntimeTranslationsResponse{
|
|
Lang: lang,
|
|
ByKey: map[string]string{},
|
|
ByText: map[string]string{},
|
|
}
|
|
|
|
rows, err := db.Query(`
|
|
WITH base AS (
|
|
SELECT DISTINCT ON (t_key)
|
|
t_key,
|
|
COALESCE(NULLIF(source_text_tr, ''), translated_text, '') AS source_text_tr
|
|
FROM mk_translator
|
|
WHERE COALESCE(NULLIF(source_text_tr, ''), translated_text, '') <> ''
|
|
AND status = 'approved'
|
|
AND (
|
|
COALESCE(NULLIF(provider_meta->>'source_type', ''), 'dummy') <> 'dummy'
|
|
OR is_manual = true
|
|
)
|
|
AND char_length(COALESCE(NULLIF(source_text_tr, ''), translated_text, '')) <= 180
|
|
AND COALESCE(NULLIF(source_text_tr, ''), translated_text, '') !~ '[<>{}\[\]]'
|
|
AND position(chr(96) in COALESCE(NULLIF(source_text_tr, ''), translated_text, '')) = 0
|
|
AND COALESCE(NULLIF(source_text_tr, ''), translated_text, '') !~ '(=>|\|\||&&|\?\?|/\*|\*/|\.trim\(|\.replace\(|\.map\(|\.filter\()'
|
|
ORDER BY t_key, CASE WHEN lang_code='tr' THEN 0 ELSE 1 END, updated_at DESC
|
|
),
|
|
target AS (
|
|
SELECT DISTINCT ON (t_key)
|
|
t_key,
|
|
COALESCE(translated_text, '') AS translated_text
|
|
FROM mk_translator
|
|
WHERE lang_code=$1
|
|
AND (status = 'approved' OR is_manual = true)
|
|
ORDER BY t_key, is_manual DESC, updated_at DESC
|
|
)
|
|
SELECT
|
|
base.t_key,
|
|
base.source_text_tr,
|
|
CASE
|
|
WHEN $1 = 'tr' THEN base.source_text_tr
|
|
ELSE COALESCE(NULLIF(target.translated_text, ''), base.source_text_tr)
|
|
END AS display_text
|
|
FROM base
|
|
LEFT JOIN target ON target.t_key = base.t_key
|
|
`, lang)
|
|
if err != nil {
|
|
http.Error(w, "runtime translations query error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var key, sourceText, displayText string
|
|
if err := rows.Scan(&key, &sourceText, &displayText); err != nil {
|
|
http.Error(w, "runtime translations scan error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
sourceText = normalizeRuntimeTranslationText(sourceText)
|
|
displayText = normalizeRuntimeTranslationText(displayText)
|
|
if key != "" && displayText != "" {
|
|
resp.ByKey[key] = displayText
|
|
}
|
|
if sourceText != "" && displayText != "" {
|
|
resp.ByText[sourceText] = displayText
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
http.Error(w, "runtime translations rows error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("TRANSLATION_DUMMY_SEEDS_ENABLED")), "true") {
|
|
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
|
|
}
|
|
if !shouldCollectDummySeedFile(uiRoot, path, ext) {
|
|
return nil
|
|
}
|
|
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
texts := extractVisibleUIText(string(b), ext)
|
|
for _, text := range texts {
|
|
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 shouldCollectDummySeedFile(uiRoot, fullPath, ext string) bool {
|
|
rel, err := filepath.Rel(uiRoot, fullPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
rel = strings.ToLower(filepath.ToSlash(rel))
|
|
if strings.Contains(rel, "/__tests__/") || strings.Contains(rel, "/tests/") || strings.Contains(rel, "/mock/") || strings.Contains(rel, "/mocks/") {
|
|
return false
|
|
}
|
|
|
|
if ext == ".vue" {
|
|
for _, prefix := range translationDummyAllowedVueDirs {
|
|
if strings.HasPrefix(rel, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if ext == ".js" || ext == ".ts" {
|
|
for _, prefix := range translationDummyAllowedStoreDirs {
|
|
if strings.HasPrefix(rel, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func extractVisibleUIText(content string, ext string) []string {
|
|
out := make([]string, 0, 32)
|
|
seen := map[string]struct{}{}
|
|
isLikelyAttrNoise := func(text string) bool {
|
|
tokens := strings.Fields(strings.ToLower(text))
|
|
if len(tokens) < 2 || len(tokens) > 16 {
|
|
return false
|
|
}
|
|
matched := 0
|
|
for _, t := range tokens {
|
|
if _, ok := translationNoiseTokens[t]; ok {
|
|
matched++
|
|
continue
|
|
}
|
|
if strings.HasPrefix(t, ":") || strings.HasPrefix(t, "@") || strings.HasPrefix(t, "v-") || strings.HasPrefix(t, "#") {
|
|
matched++
|
|
continue
|
|
}
|
|
}
|
|
return matched == len(tokens)
|
|
}
|
|
appendText := func(raw string) {
|
|
if strings.ContainsAny(raw, "\r\n\t") {
|
|
return
|
|
}
|
|
text := strings.TrimSpace(strings.Join(strings.Fields(raw), " "))
|
|
if text == "" {
|
|
return
|
|
}
|
|
if strings.ContainsAny(text, "<>{}[]`") {
|
|
return
|
|
}
|
|
if strings.Contains(text, "=") || strings.Contains(text, "#") {
|
|
return
|
|
}
|
|
if reTemplateDynamic.MatchString(text) {
|
|
return
|
|
}
|
|
if isLikelyAttrNoise(text) {
|
|
return
|
|
}
|
|
if _, ok := seen[text]; ok {
|
|
return
|
|
}
|
|
seen[text] = struct{}{}
|
|
out = append(out, text)
|
|
}
|
|
|
|
switch ext {
|
|
case ".vue":
|
|
template := content
|
|
if m := reVueTemplate.FindStringSubmatch(content); len(m) > 1 {
|
|
template = m[1]
|
|
}
|
|
for _, m := range reTemplateAttr.FindAllStringSubmatch(template, -1) {
|
|
if len(m) > 1 {
|
|
appendText(m[1])
|
|
}
|
|
}
|
|
for _, m := range reTemplateText.FindAllStringSubmatch(template, -1) {
|
|
if len(m) > 1 {
|
|
appendText(m[1])
|
|
}
|
|
}
|
|
script := content
|
|
if m := reVueScript.FindStringSubmatch(content); len(m) > 1 {
|
|
script = m[1]
|
|
}
|
|
for _, m := range reScriptLabelProp.FindAllStringSubmatch(script, -1) {
|
|
if len(m) > 1 {
|
|
appendText(m[1])
|
|
}
|
|
}
|
|
for _, m := range reScriptUIProp.FindAllStringSubmatch(script, -1) {
|
|
if len(m) > 1 {
|
|
appendText(m[1])
|
|
}
|
|
}
|
|
case ".js", ".ts":
|
|
for _, m := range reScriptUIProp.FindAllStringSubmatch(content, -1) {
|
|
if len(m) > 1 {
|
|
appendText(m[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
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{}
|
|
var processedCount int64
|
|
var translatedCount int64
|
|
var failedTranslateCount int64
|
|
var failedUpdateCount int64
|
|
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()
|
|
heartbeatDone := make(chan struct{})
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(progressTicker)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
processed := int(atomic.LoadInt64(&processedCount))
|
|
translated := int(atomic.LoadInt64(&translatedCount))
|
|
failedTr := int(atomic.LoadInt64(&failedTranslateCount))
|
|
failedUpd := int(atomic.LoadInt64(&failedUpdateCount))
|
|
elapsed := time.Since(start)
|
|
rps := float64(translated)
|
|
if elapsed > 0 {
|
|
rps = float64(translated) / elapsed.Seconds()
|
|
}
|
|
log.Printf(
|
|
"[TranslationAuto] trace=%s stage=heartbeat processed=%d/%d translated=%d failed_translate=%d failed_update=%d elapsed_ms=%d rps=%.2f",
|
|
traceID,
|
|
processed,
|
|
len(list),
|
|
translated,
|
|
failedTr,
|
|
failedUpd,
|
|
elapsed.Milliseconds(),
|
|
rps,
|
|
)
|
|
case <-heartbeatDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
defer close(heartbeatDone)
|
|
|
|
for i, p := range list {
|
|
tr, err := callAzureTranslate(p.Text, p.Lang)
|
|
if err != nil || strings.TrimSpace(tr) == "" {
|
|
failedTranslate++
|
|
atomic.StoreInt64(&failedTranslateCount, int64(failedTranslate))
|
|
atomic.StoreInt64(&processedCount, int64(i+1))
|
|
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++
|
|
atomic.StoreInt64(&failedUpdateCount, int64(failedUpdate))
|
|
atomic.StoreInt64(&processedCount, int64(i+1))
|
|
continue
|
|
}
|
|
done++
|
|
atomic.StoreInt64(&translatedCount, int64(done))
|
|
atomic.StoreInt64(&processedCount, int64(i+1))
|
|
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 normalizeRuntimeTranslationLang(v string) string {
|
|
raw := strings.ToLower(strings.TrimSpace(v))
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(raw, ",") {
|
|
raw = strings.TrimSpace(strings.Split(raw, ",")[0])
|
|
}
|
|
if strings.Contains(raw, ";") {
|
|
raw = strings.TrimSpace(strings.Split(raw, ";")[0])
|
|
}
|
|
if strings.Contains(raw, "-") {
|
|
raw = strings.TrimSpace(strings.Split(raw, "-")[0])
|
|
}
|
|
if strings.Contains(raw, "_") {
|
|
raw = strings.TrimSpace(strings.Split(raw, "_")[0])
|
|
}
|
|
return normalizeTranslationLang(raw)
|
|
}
|
|
|
|
func normalizeRuntimeTranslationText(v string) string {
|
|
return strings.Join(strings.Fields(strings.TrimSpace(v)), " ")
|
|
}
|
|
|
|
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
|
|
}
|
|
if reCodeLikeText.MatchString(s) {
|
|
return false
|
|
}
|
|
if strings.ContainsAny(s, "{}[];`") {
|
|
return false
|
|
}
|
|
symbolCount := 0
|
|
for _, r := range s {
|
|
switch r {
|
|
case '(', ')', '=', ':', '/', '\\', '|', '&', '*', '<', '>', '_':
|
|
symbolCount++
|
|
}
|
|
}
|
|
if symbolCount >= 4 {
|
|
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
|
|
}
|