diff --git a/svc/main.go b/svc/main.go index 195cc20..3b7f584 100644 --- a/svc/main.go +++ b/svc/main.go @@ -395,6 +395,10 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "language", "update", wrapV3(routes.GetTranslationRowsHandler(pgDB)), ) + r.Handle( + "/api/language/translations/runtime", + wrapAuthOnly(http.HandlerFunc(routes.GetRuntimeTranslationsHandler(pgDB))), + ).Methods("GET", "OPTIONS") bindV3(r, pgDB, "/api/language/translations/{id}", "PUT", "language", "update", diff --git a/svc/routes/product_series.go b/svc/routes/product_series.go index 761d976..935ac32 100644 --- a/svc/routes/product_series.go +++ b/svc/routes/product_series.go @@ -695,13 +695,13 @@ LIMIT 1 row.MappingReady = false switch { case row.MmitemID <= 0: - row.MappingWarning = "B2B'de urun yok (mmitem)" + row.MappingWarning = "B2B'de urun yok" case row.Dim1ID <= 0: - row.MappingWarning = "B2B'de renk bu urunde yok (mmitem_dim/dfgrp.code)" + row.MappingWarning = "B2B'de bu urun icin renk yok" case row.Dim3Code != "" && row.Dim3ID <= 0: - row.MappingWarning = "B2B'de dim3 token eslesmesi yok (mk_dim_token_map: dimval3)" + row.MappingWarning = "B2B'de ikinci renk eslesmesi yok" case !comboOK: - row.MappingWarning = "B2B'de varyant kombosu yok (mmitem_dim)" + row.MappingWarning = "B2B'de bu urun/renk/ikinci renk kombinasyonu yok" default: // Not an orphan; skip. if baseReady && comboOK { @@ -861,7 +861,7 @@ func PostProductSeriesMappingsSaveHandler(pg *sql.DB) http.HandlerFunc { } mmitemID, err := resolveMmitemIDTx(ctx, tx, code) if err != nil || mmitemID <= 0 { - http.Error(w, "PG urun bulunamadi: "+code, http.StatusBadRequest) + http.Error(w, "B2B urun bulunamadi: "+code, http.StatusBadRequest) return } // Authoritative dim1 resolver: only allow saving against a color that exists for this product in mmitem_dim. diff --git a/svc/routes/translations.go b/svc/routes/translations.go index 25c6a57..9c6e50a 100644 --- a/svc/routes/translations.go +++ b/svc/routes/translations.go @@ -134,6 +134,12 @@ type TranslateSelectedPayload struct { 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"` @@ -296,6 +302,82 @@ ORDER BY t_key, lang_code } } +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, '') <> '' + 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 + 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") @@ -1667,6 +1749,30 @@ func normalizeTranslationLang(v string) string { 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 { diff --git a/ui/src/boot/locale.js b/ui/src/boot/locale.js index e2ee4a2..d8ffb55 100644 --- a/ui/src/boot/locale.js +++ b/ui/src/boot/locale.js @@ -1,7 +1,10 @@ import { boot } from 'quasar/wrappers' import { useLocaleStore } from 'src/stores/localeStore' +import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore' -export default boot(() => { +export default boot(async () => { const localeStore = useLocaleStore() localeStore.setLocale(localeStore.locale) + const runtimeTranslations = useRuntimeTranslationStore() + await runtimeTranslations.loadLocale(localeStore.locale) }) diff --git a/ui/src/composables/useI18n.js b/ui/src/composables/useI18n.js index 7e6988f..c856a4f 100644 --- a/ui/src/composables/useI18n.js +++ b/ui/src/composables/useI18n.js @@ -3,6 +3,7 @@ import { computed } from 'vue' import { messages } from 'src/i18n/messages' import { DEFAULT_LOCALE } from 'src/i18n/languages' import { useLocaleStore } from 'src/stores/localeStore' +import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore' function lookup(obj, path) { return String(path || '') @@ -13,6 +14,7 @@ function lookup(obj, path) { export function useI18n() { const localeStore = useLocaleStore() + const runtimeTranslations = useRuntimeTranslationStore() const currentLocale = computed(() => localeStore.locale) @@ -24,6 +26,11 @@ export function useI18n() { } function t(key) { + const runtimeValue = runtimeTranslations.translateKey(key) + if (runtimeValue && runtimeValue !== key) { + return runtimeValue + } + for (const locale of fallbackLocales(currentLocale.value)) { const val = lookup(messages[locale] || {}, key) if (val != null) return val diff --git a/ui/src/composables/useRuntimeDomTranslations.js b/ui/src/composables/useRuntimeDomTranslations.js new file mode 100644 index 0000000..1dda758 --- /dev/null +++ b/ui/src/composables/useRuntimeDomTranslations.js @@ -0,0 +1,157 @@ +import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue' +import { useRoute } from 'vue-router' + +import { DEFAULT_LOCALE } from 'src/i18n/languages' +import { useLocaleStore } from 'src/stores/localeStore' +import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore' + +const textOriginals = new WeakMap() +const attrOriginals = new WeakMap() +const TRANSLATABLE_ATTRS = ['placeholder', 'title', 'aria-label'] +const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA']) + +function normalizeRuntimeText (value) { + return String(value || '').trim().replace(/\s+/g, ' ') +} + +function preserveOuterWhitespace (original, translated) { + const text = String(original || '') + const leading = text.match(/^\s*/)?.[0] || '' + const trailing = text.match(/\s*$/)?.[0] || '' + return `${leading}${translated}${trailing}` +} + +function shouldSkipNode (node) { + const parent = node?.parentElement + if (!parent) return true + if (SKIP_TAGS.has(parent.tagName)) return true + if (parent.closest?.('[data-no-runtime-i18n="true"]')) return true + return false +} + +export function useRuntimeDomTranslations () { + const route = useRoute() + const localeStore = useLocaleStore() + const runtimeTranslations = useRuntimeTranslationStore() + let observer = null + let applyTimer = null + + function translateTextNode (node) { + if (shouldSkipNode(node)) return + const current = String(node.nodeValue || '') + if (!normalizeRuntimeText(current)) return + + if (!textOriginals.has(node)) { + textOriginals.set(node, current) + } + const original = textOriginals.get(node) + const key = normalizeRuntimeText(original) + const translated = localeStore.locale === DEFAULT_LOCALE + ? key + : runtimeTranslations.byText[key] + + const nextValue = preserveOuterWhitespace(original, translated || key) + if (node.nodeValue !== nextValue) { + node.nodeValue = nextValue + } + } + + function translateElementAttrs (el) { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return + if (el.closest?.('[data-no-runtime-i18n="true"]')) return + let originals = attrOriginals.get(el) + if (!originals) { + originals = {} + attrOriginals.set(el, originals) + } + + for (const attr of TRANSLATABLE_ATTRS) { + if (!el.hasAttribute(attr)) continue + const current = el.getAttribute(attr) + if (!normalizeRuntimeText(current)) continue + if (!Object.prototype.hasOwnProperty.call(originals, attr)) { + originals[attr] = current + } + const key = normalizeRuntimeText(originals[attr]) + const translated = localeStore.locale === DEFAULT_LOCALE + ? key + : runtimeTranslations.byText[key] + const nextValue = translated || key + if (el.getAttribute(attr) !== nextValue) { + el.setAttribute(attr, nextValue) + } + } + } + + function translateRoot (root = document.body) { + if (typeof document === 'undefined' || !root) return + + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + { + acceptNode (node) { + if (node.nodeType === Node.TEXT_NODE) { + return normalizeRuntimeText(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + } + return NodeFilter.FILTER_ACCEPT + } + } + ) + + let node = walker.currentNode + while (node) { + if (node.nodeType === Node.TEXT_NODE) { + translateTextNode(node) + } else if (node.nodeType === Node.ELEMENT_NODE) { + translateElementAttrs(node) + } + node = walker.nextNode() + } + } + + function scheduleApply () { + if (applyTimer) clearTimeout(applyTimer) + applyTimer = setTimeout(async () => { + applyTimer = null + await nextTick() + translateRoot(document.body) + }, 30) + } + + onMounted(async () => { + await runtimeTranslations.loadLocale(localeStore.locale) + scheduleApply() + observer = new MutationObserver(() => scheduleApply()) + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: TRANSLATABLE_ATTRS + }) + }) + + watch( + () => localeStore.locale, + async (locale) => { + await runtimeTranslations.loadLocale(locale) + scheduleApply() + } + ) + + watch( + () => route.fullPath, + () => scheduleApply() + ) + + watch( + () => runtimeTranslations.version, + () => scheduleApply() + ) + + onBeforeUnmount(() => { + if (observer) observer.disconnect() + if (applyTimer) clearTimeout(applyTimer) + }) +} diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 4b8c756..530770f 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -171,6 +171,7 @@ import { Dialog, useQuasar } from 'quasar' import { useAuthStore } from 'stores/authStore' import { usePermissionStore } from 'stores/permissionStore' import { useI18n } from 'src/composables/useI18n' +import { useRuntimeDomTranslations } from 'src/composables/useRuntimeDomTranslations' import { UI_LANGUAGE_OPTIONS } from 'src/i18n/languages' import { useLocaleStore } from 'src/stores/localeStore' import { activityLogsMenuItem, getAuthUserId } from 'src/modules/activityLogs' @@ -185,6 +186,7 @@ const auth = useAuthStore() const perm = usePermissionStore() const localeStore = useLocaleStore() const { t } = useI18n() +useRuntimeDomTranslations() const languageOptions = UI_LANGUAGE_OPTIONS const selectedLocale = computed({ diff --git a/ui/src/pages/BrandClassification.vue b/ui/src/pages/BrandClassification.vue index 1cc9578..81a48bf 100644 --- a/ui/src/pages/BrandClassification.vue +++ b/ui/src/pages/BrandClassification.vue @@ -4,7 +4,7 @@