diff --git a/backend/app/api/translations.py b/backend/app/api/translations.py deleted file mode 100644 index 708f254..0000000 --- a/backend/app/api/translations.py +++ /dev/null @@ -1,1053 +0,0 @@ -# Translation API with default dictionaries -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from sqlalchemy import or_ -from typing import Optional, List -from ..database import get_db -from ..models import Translation -from ..schemas import ( - TranslationCreate, TranslationUpdate, TranslationResponse, - TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse, -) - -router = APIRouter(prefix="/translations", tags=["translations"]) - -# Categories for translations -TRANSLATION_CATEGORIES = [ - "maker", # Car makers (Hyundai, Kia, etc.) - "model", # Car models (Sonata, K5, etc.) - "fuel", # Fuel types (가솔린, 디젤, etc.) - "transmission", # Transmission types (자동, 수동, etc.) - "color", # Colors (흰색, 검정색, etc.) - "car_name", # Full car names - "general", # General terms -] - -# Default translation dictionary for common Korean terms -DEFAULT_TRANSLATIONS = { - # Fuel types (연료) - "fuel": { - "가솔린": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"}, - "휘발유": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"}, - "디젤": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"}, - "경유": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"}, - "LPG": {"en": "LPG", "mn": "LPG", "ru": "Газ"}, - "가스": {"en": "LPG", "mn": "LPG", "ru": "Газ"}, - "전기": {"en": "Electric", "mn": "Цахилгаан", "ru": "Электро"}, - "하이브리드": {"en": "Hybrid", "mn": "Хайбрид", "ru": "Гибрид"}, - "플러그인하이브리드": {"en": "Plug-in Hybrid", "mn": "Плаг-ин Хайбрид", "ru": "Плагин гибрид"}, - "수소": {"en": "Hydrogen", "mn": "Устөрөгч", "ru": "Водород"}, - "CNG": {"en": "CNG", "mn": "CNG", "ru": "Метан"}, - }, - # Transmission types (변속기) - "transmission": { - "자동": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"}, - "오토": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"}, - "수동": {"en": "Manual", "mn": "Механик", "ru": "Механика"}, - "스틱": {"en": "Manual", "mn": "Механик", "ru": "Механика"}, - "CVT": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"}, - "무단변속기": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"}, - "DCT": {"en": "DCT", "mn": "DCT", "ru": "Робот"}, - "듀얼클러치": {"en": "Dual Clutch", "mn": "Давхар холбоо", "ru": "Робот"}, - "세미오토": {"en": "Semi-Auto", "mn": "Хагас автомат", "ru": "Полуавтомат"}, - }, - # Colors (색상) - "color": { - "흰색": {"en": "White", "mn": "Цагаан", "ru": "Белый"}, - "화이트": {"en": "White", "mn": "Цагаан", "ru": "Белый"}, - "백색": {"en": "White", "mn": "Цагаан", "ru": "Белый"}, - "검정": {"en": "Black", "mn": "Хар", "ru": "Чёрный"}, - "검정색": {"en": "Black", "mn": "Хар", "ru": "Чёрный"}, - "블랙": {"en": "Black", "mn": "Хар", "ru": "Чёрный"}, - "은색": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"}, - "실버": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"}, - "회색": {"en": "Gray", "mn": "Саарал", "ru": "Серый"}, - "그레이": {"en": "Gray", "mn": "Саарал", "ru": "Серый"}, - "진회색": {"en": "Dark Gray", "mn": "Бараан саарал", "ru": "Тёмно-серый"}, - "빨강": {"en": "Red", "mn": "Улаан", "ru": "Красный"}, - "빨간색": {"en": "Red", "mn": "Улаан", "ru": "Красный"}, - "레드": {"en": "Red", "mn": "Улаан", "ru": "Красный"}, - "파랑": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, - "파란색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, - "블루": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, - "남색": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"}, - "네이비": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"}, - "하늘색": {"en": "Sky Blue", "mn": "Тэнгэрийн хөх", "ru": "Голубой"}, - "녹색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"}, - "초록색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"}, - "그린": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"}, - "노랑": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"}, - "노란색": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"}, - "옐로우": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"}, - "주황색": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"}, - "오렌지": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"}, - "갈색": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"}, - "브라운": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"}, - "베이지": {"en": "Beige", "mn": "Бээж", "ru": "Бежевый"}, - "아이보리": {"en": "Ivory", "mn": "Зүс өнгө", "ru": "Слоновая кость"}, - "진주색": {"en": "Pearl", "mn": "Сувдан", "ru": "Перламутр"}, - "펄화이트": {"en": "Pearl White", "mn": "Сувдан цагаан", "ru": "Перламутровый белый"}, - "보라색": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"}, - "퍼플": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"}, - "분홍색": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"}, - "핑크": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"}, - "와인": {"en": "Wine", "mn": "Дарс өнгө", "ru": "Бордовый"}, - "버건디": {"en": "Burgundy", "mn": "Бургунд", "ru": "Бургунди"}, - "골드": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"}, - "금색": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"}, - "샴페인": {"en": "Champagne", "mn": "Шампан", "ru": "Шампань"}, - "청색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, - "연두색": {"en": "Light Green", "mn": "Цайвар ногоон", "ru": "Светло-зелёный"}, - "민트": {"en": "Mint", "mn": "Минт", "ru": "Мятный"}, - "카키": {"en": "Khaki", "mn": "Хаки", "ru": "Хаки"}, - "올리브": {"en": "Olive", "mn": "Чидун ногоон", "ru": "Оливковый"}, - "투톤": {"en": "Two-Tone", "mn": "Хоёр өнгө", "ru": "Двухцветный"}, - "기타": {"en": "Other", "mn": "Бусад", "ru": "Другой"}, - }, - # Car makers (제조사) - "maker": { - "현대": {"en": "Hyundai", "mn": "Хёндай", "ru": "Хендай"}, - "기아": {"en": "Kia", "mn": "Киа", "ru": "Киа"}, - "제네시스": {"en": "Genesis", "mn": "Женезис", "ru": "Дженезис"}, - "쉐보레": {"en": "Chevrolet", "mn": "Шевроле", "ru": "Шевроле"}, - "르노삼성": {"en": "Renault Samsung", "mn": "Рено Самсунг", "ru": "Рено Самсунг"}, - "르노코리아": {"en": "Renault Korea", "mn": "Рено Солонгос", "ru": "Рено Корея"}, - "쌍용": {"en": "SsangYong", "mn": "Ссанёнг", "ru": "СсангЙонг"}, - "KG모빌리티": {"en": "KG Mobility", "mn": "KG Мобилити", "ru": "КГ Мобилити"}, - "BMW": {"en": "BMW", "mn": "BMW", "ru": "БМВ"}, - "벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"}, - "메르세데스벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"}, - "아우디": {"en": "Audi", "mn": "Ауди", "ru": "Ауди"}, - "폭스바겐": {"en": "Volkswagen", "mn": "Фольксваген", "ru": "Фольксваген"}, - "토요타": {"en": "Toyota", "mn": "Тойота", "ru": "Тойота"}, - "혼다": {"en": "Honda", "mn": "Хонда", "ru": "Хонда"}, - "닛산": {"en": "Nissan", "mn": "Ниссан", "ru": "Ниссан"}, - "렉서스": {"en": "Lexus", "mn": "Лексус", "ru": "Лексус"}, - "인피니티": {"en": "Infiniti", "mn": "Инфинити", "ru": "Инфинити"}, - "마쯔다": {"en": "Mazda", "mn": "Мазда", "ru": "Мазда"}, - "스바루": {"en": "Subaru", "mn": "Субару", "ru": "Субару"}, - "미쓰비시": {"en": "Mitsubishi", "mn": "Мицубиши", "ru": "Митсубиши"}, - "포드": {"en": "Ford", "mn": "Форд", "ru": "Форд"}, - "링컨": {"en": "Lincoln", "mn": "Линкольн", "ru": "Линкольн"}, - "캐딜락": {"en": "Cadillac", "mn": "Кадиллак", "ru": "Кадиллак"}, - "지프": {"en": "Jeep", "mn": "Жийп", "ru": "Джип"}, - "크라이슬러": {"en": "Chrysler", "mn": "Крайслер", "ru": "Крайслер"}, - "테슬라": {"en": "Tesla", "mn": "Тесла", "ru": "Тесла"}, - "볼보": {"en": "Volvo", "mn": "Вольво", "ru": "Вольво"}, - "포르쉐": {"en": "Porsche", "mn": "Порше", "ru": "Порше"}, - "재규어": {"en": "Jaguar", "mn": "Ягуар", "ru": "Ягуар"}, - "랜드로버": {"en": "Land Rover", "mn": "Лэнд Ровер", "ru": "Лэнд Ровер"}, - "미니": {"en": "Mini", "mn": "Мини", "ru": "Мини"}, - "페라리": {"en": "Ferrari", "mn": "Феррари", "ru": "Феррари"}, - "람보르기니": {"en": "Lamborghini", "mn": "Ламборгини", "ru": "Ламборгини"}, - "벤틀리": {"en": "Bentley", "mn": "Бентли", "ru": "Бентли"}, - "롤스로이스": {"en": "Rolls-Royce", "mn": "Роллс-Ройс", "ru": "Роллс-Ройс"}, - "마세라티": {"en": "Maserati", "mn": "Мазерати", "ru": "Мазерати"}, - "알파로메오": {"en": "Alfa Romeo", "mn": "Альфа Ромео", "ru": "Альфа Ромео"}, - "피아트": {"en": "Fiat", "mn": "Фиат", "ru": "Фиат"}, - "푸조": {"en": "Peugeot", "mn": "Пежо", "ru": "Пежо"}, - "시트로엥": {"en": "Citroen", "mn": "Ситроен", "ru": "Ситроен"}, - "르노": {"en": "Renault", "mn": "Рено", "ru": "Рено"}, - "스즈키": {"en": "Suzuki", "mn": "Сузуки", "ru": "Сузуки"}, - "다이하쓰": {"en": "Daihatsu", "mn": "Дайхатсу", "ru": "Дайхатсу"}, - "이스즈": {"en": "Isuzu", "mn": "Исузу", "ru": "Исузу"}, - "GMC": {"en": "GMC", "mn": "GMC", "ru": "ДжиЭмСи"}, - "대우": {"en": "Daewoo", "mn": "Дэу", "ru": "Дэу"}, - "아큐라": {"en": "Acura", "mn": "Акура", "ru": "Акура"}, - "뷰익": {"en": "Buick", "mn": "Бюик", "ru": "Бьюик"}, - "스마트": {"en": "Smart", "mn": "Смарт", "ru": "Смарт"}, - "BYD": {"en": "BYD", "mn": "BYD", "ru": "БИД"}, - }, - # Car models (차량 모델) - These generally keep their names but with transliteration - "model": { - # Hyundai models - "소나타": {"en": "Sonata", "mn": "Соната", "ru": "Соната"}, - "아반떼": {"en": "Avante", "mn": "Аванте", "ru": "Аванте"}, - "그랜저": {"en": "Grandeur", "mn": "Грандер", "ru": "Грандер"}, - "싼타페": {"en": "Santa Fe", "mn": "Санта Фе", "ru": "Санта Фе"}, - "투싼": {"en": "Tucson", "mn": "Туксон", "ru": "Туксон"}, - "팰리세이드": {"en": "Palisade", "mn": "Палисейд", "ru": "Палисад"}, - "스타리아": {"en": "Staria", "mn": "Стариа", "ru": "Стариа"}, - "아이오닉": {"en": "Ioniq", "mn": "Ионик", "ru": "Ионик"}, - "코나": {"en": "Kona", "mn": "Кона", "ru": "Кона"}, - "베뉴": {"en": "Venue", "mn": "Венью", "ru": "Венью"}, - "넥쏘": {"en": "Nexo", "mn": "Нексо", "ru": "Нексо"}, - "캐스퍼": {"en": "Casper", "mn": "Каспер", "ru": "Каспер"}, - "엘란트라": {"en": "Elantra", "mn": "Элантра", "ru": "Элантра"}, - "액센트": {"en": "Accent", "mn": "Акцент", "ru": "Акцент"}, - "벨로스터": {"en": "Veloster", "mn": "Велостер", "ru": "Велостер"}, - "i30": {"en": "i30", "mn": "i30", "ru": "i30"}, - "i40": {"en": "i40", "mn": "i40", "ru": "i40"}, - # Kia models - "쏘렌토": {"en": "Sorento", "mn": "Соренто", "ru": "Соренто"}, - "스포티지": {"en": "Sportage", "mn": "Спортейж", "ru": "Спортейдж"}, - "카니발": {"en": "Carnival", "mn": "Карнивал", "ru": "Карнивал"}, - "셀토스": {"en": "Seltos", "mn": "Сельтос", "ru": "Селтос"}, - "모하비": {"en": "Mohave", "mn": "Мохаве", "ru": "Мохаве"}, - "니로": {"en": "Niro", "mn": "Ниро", "ru": "Ниро"}, - "스팅어": {"en": "Stinger", "mn": "Стингер", "ru": "Стингер"}, - "레이": {"en": "Ray", "mn": "Рэй", "ru": "Рэй"}, - "모닝": {"en": "Morning", "mn": "Морнинг", "ru": "Морнинг"}, - "쏘울": {"en": "Soul", "mn": "Соул", "ru": "Соул"}, - "K3": {"en": "K3", "mn": "K3", "ru": "K3"}, - "K5": {"en": "K5", "mn": "K5", "ru": "K5"}, - "K7": {"en": "K7", "mn": "K7", "ru": "K7"}, - "K8": {"en": "K8", "mn": "K8", "ru": "K8"}, - "K9": {"en": "K9", "mn": "K9", "ru": "K9"}, - "EV6": {"en": "EV6", "mn": "EV6", "ru": "EV6"}, - "EV9": {"en": "EV9", "mn": "EV9", "ru": "EV9"}, - # Genesis models - "G70": {"en": "G70", "mn": "G70", "ru": "G70"}, - "G80": {"en": "G80", "mn": "G80", "ru": "G80"}, - "G90": {"en": "G90", "mn": "G90", "ru": "G90"}, - "GV60": {"en": "GV60", "mn": "GV60", "ru": "GV60"}, - "GV70": {"en": "GV70", "mn": "GV70", "ru": "GV70"}, - "GV80": {"en": "GV80", "mn": "GV80", "ru": "GV80"}, - # SsangYong / KG Mobility models - "렉스턴": {"en": "Rexton", "mn": "Рекстон", "ru": "Рекстон"}, - "코란도": {"en": "Korando", "mn": "Корандо", "ru": "Корандо"}, - "티볼리": {"en": "Tivoli", "mn": "Тиволи", "ru": "Тиволи"}, - "토레스": {"en": "Torres", "mn": "Торрес", "ru": "Торрес"}, - "무쏘": {"en": "Musso", "mn": "Муссо", "ru": "Муссо"}, - # Chevrolet models - "말리부": {"en": "Malibu", "mn": "Малибу", "ru": "Малибу"}, - "트래버스": {"en": "Traverse", "mn": "Траверс", "ru": "Траверс"}, - "트랙스": {"en": "Trax", "mn": "Тракс", "ru": "Тракс"}, - "이쿼녹스": {"en": "Equinox", "mn": "Эквинокс", "ru": "Эквинокс"}, - "스파크": {"en": "Spark", "mn": "Спарк", "ru": "Спарк"}, - "볼트": {"en": "Bolt", "mn": "Болт", "ru": "Болт"}, - # Renault Samsung models - "SM6": {"en": "SM6", "mn": "SM6", "ru": "SM6"}, - "QM6": {"en": "QM6", "mn": "QM6", "ru": "QM6"}, - "XM3": {"en": "XM3", "mn": "XM3", "ru": "XM3"}, - }, -} - - -@router.get("/categories") -def get_categories(): - """Get available translation categories""" - return TRANSLATION_CATEGORIES - - -@router.get("", response_model=TranslationListResponse) -def get_translations( - page: int = Query(1, ge=1), - page_size: int = Query(50, ge=1, le=200), - category: Optional[str] = None, - search: Optional[str] = None, - db: Session = Depends(get_db), -): - """Get all translations with pagination and filtering""" - query = db.query(Translation) - - if category: - query = query.filter(Translation.category == category) - - if search: - search_term = f"%{search}%" - query = query.filter( - or_( - Translation.source_text.ilike(search_term), - Translation.text_en.ilike(search_term), - Translation.text_mn.ilike(search_term), - Translation.text_ru.ilike(search_term), - ) - ) - - total = query.count() - translations = query.order_by( - Translation.category, Translation.source_text - ).offset((page - 1) * page_size).limit(page_size).all() - - return TranslationListResponse( - total=total, - page=page, - page_size=page_size, - translations=translations - ) - - -@router.get("/{translation_id}", response_model=TranslationResponse) -def get_translation(translation_id: int, db: Session = Depends(get_db)): - """Get a specific translation by ID""" - translation = db.query(Translation).filter(Translation.id == translation_id).first() - if not translation: - raise HTTPException(status_code=404, detail="Translation not found") - return translation - - -@router.post("", response_model=TranslationResponse) -def create_translation(data: TranslationCreate, db: Session = Depends(get_db)): - """Create a new translation""" - # Check if already exists - existing = db.query(Translation).filter( - Translation.source_text == data.source_text, - Translation.category == data.category - ).first() - - if existing: - raise HTTPException(status_code=400, detail="Translation already exists for this text and category") - - translation = Translation(**data.dict()) - db.add(translation) - db.commit() - db.refresh(translation) - return translation - - -@router.put("/{translation_id}", response_model=TranslationResponse) -def update_translation( - translation_id: int, - data: TranslationUpdate, - db: Session = Depends(get_db) -): - """Update a translation""" - translation = db.query(Translation).filter(Translation.id == translation_id).first() - if not translation: - raise HTTPException(status_code=404, detail="Translation not found") - - for key, value in data.dict(exclude_unset=True).items(): - setattr(translation, key, value) - - db.commit() - db.refresh(translation) - return translation - - -@router.delete("/{translation_id}") -def delete_translation(translation_id: int, db: Session = Depends(get_db)): - """Delete a translation""" - translation = db.query(Translation).filter(Translation.id == translation_id).first() - if not translation: - raise HTTPException(status_code=404, detail="Translation not found") - - db.delete(translation) - db.commit() - return {"message": "Translation deleted"} - - -@router.post("/bulk-lookup", response_model=TranslationBulkResponse) -def bulk_lookup( - request: TranslationBulkRequest, - db: Session = Depends(get_db) -): - """Lookup translations for multiple texts at once""" - if not request.texts: - return TranslationBulkResponse(translations={}) - - query = db.query(Translation).filter(Translation.source_text.in_(request.texts)) - - if request.category: - query = query.filter(Translation.category == request.category) - - translations_db = query.all() - - # Build result dictionary - result = {} - lang_field = f"text_{request.lang}" - - for trans in translations_db: - translated = getattr(trans, lang_field, None) - if translated: - result[trans.source_text] = translated - else: - # Fallback to source text if no translation - result[trans.source_text] = trans.source_text - - # For texts not found in DB, return original - for text in request.texts: - if text not in result: - result[text] = text - - return TranslationBulkResponse(translations=result) - - -@router.get("/lookup/{text}") -def lookup_single( - text: str, - lang: str = Query("en", description="Target language: en, mn, ru"), - category: Optional[str] = None, - db: Session = Depends(get_db) -): - """Lookup translation for a single text""" - query = db.query(Translation).filter(Translation.source_text == text) - - if category: - query = query.filter(Translation.category == category) - - translation = query.first() - - if not translation: - return {"source": text, "translated": text} - - lang_field = f"text_{lang}" - translated = getattr(translation, lang_field, None) - - return { - "source": text, - "translated": translated if translated else text, - "category": translation.category - } - - -def get_default_translation(source_text: str, category: str) -> dict: - """Get default translations from the predefined dictionary""" - if category in DEFAULT_TRANSLATIONS: - return DEFAULT_TRANSLATIONS[category].get(source_text, {}) - return {} - - -# Korean year suffix translations -YEAR_SUFFIX_TRANSLATIONS = { - "년형": {"en": "", "mn": " он", "ru": " г."}, - "년식": {"en": "", "mn": " он", "ru": " г."}, - "년": {"en": "", "mn": " он", "ru": " г."}, -} - - -def translate_car_name(car_name: str, lang: str, db) -> str: - """ - Translate a car name like "기아 K5 2024년형" to "Kia K5 2024" - by translating each component separately. - """ - import re - - if not car_name: - return car_name - - result = car_name - - # First, look up all translations we have in DB - all_translations = db.query(Translation).filter( - Translation.category.in_(["maker", "model", "fuel", "transmission", "color"]) - ).all() - - # Build lookup dict - trans_lookup = {} - for t in all_translations: - lang_field = f"text_{lang}" - translated = getattr(t, lang_field, None) - if translated and t.source_text: - trans_lookup[t.source_text] = translated - - # Also add from default dictionary - for category in ["maker", "model", "fuel", "transmission", "color"]: - if category in DEFAULT_TRANSLATIONS: - for korean, translations in DEFAULT_TRANSLATIONS[category].items(): - if korean not in trans_lookup and lang in translations: - trans_lookup[korean] = translations[lang] - - # Replace Korean terms with translations (longest first to avoid partial matches) - sorted_keys = sorted(trans_lookup.keys(), key=len, reverse=True) - for korean in sorted_keys: - if korean in result: - result = result.replace(korean, trans_lookup[korean]) - - # Handle year format: "2024년형" -> "2024" (en) or "2024 он" (mn) or "2024 г." (ru) - for suffix, translations in YEAR_SUFFIX_TRANSLATIONS.items(): - pattern = r'(\d{4})' + re.escape(suffix) - replacement = r'\1' + translations.get(lang, "") - result = re.sub(pattern, replacement, result) - - # Clean up extra spaces - result = re.sub(r'\s+', ' ', result).strip() - - return result - - -@router.post("/auto-extract") -def auto_extract_terms(db: Session = Depends(get_db)): - """ - Auto-extract unique terms from cars database that need translation. - This creates translation entries for makers, models, fuels, colors, etc. - Auto-fills with default translations from built-in dictionary. - """ - from ..models import Car, CarMaker, CarModel - - added_count = 0 - # Track already added entries in this batch to avoid duplicates - added_keys = set() - - def add_translation(source_text: str, category: str, text_en: str, text_mn: str, text_ru: str): - nonlocal added_count - key = (source_text, category) - if key in added_keys: - return # Skip duplicate in same batch - - existing = db.query(Translation).filter( - Translation.source_text == source_text, - Translation.category == category - ).first() - if not existing: - trans = Translation( - source_text=source_text, - category=category, - text_en=text_en, - text_mn=text_mn, - text_ru=text_ru - ) - db.add(trans) - added_keys.add(key) - added_count += 1 - - # Extract makers - makers = db.query(CarMaker).all() - for maker in makers: - if maker.name: - defaults = get_default_translation(maker.name, "maker") - add_translation( - maker.name, - "maker", - defaults.get("en") or maker.name_en or maker.name, - defaults.get("mn") or maker.name, - defaults.get("ru") or maker.name - ) - - # Extract models - models = db.query(CarModel).all() - for model in models: - if model.name: - defaults = get_default_translation(model.name, "model") - add_translation( - model.name, - "model", - defaults.get("en") or model.name_en or model.name, - defaults.get("mn") or model.name_en or model.name, - defaults.get("ru") or model.name_en or model.name - ) - - # Extract unique fuels - fuels = db.query(Car.fuel).distinct().filter(Car.fuel.isnot(None)).all() - for (fuel,) in fuels: - if fuel: - defaults = get_default_translation(fuel, "fuel") - add_translation( - fuel, - "fuel", - defaults.get("en") or fuel, - defaults.get("mn") or fuel, - defaults.get("ru") or fuel - ) - - # Extract unique transmissions - transmissions = db.query(Car.transmission).distinct().filter(Car.transmission.isnot(None)).all() - for (trans_type,) in transmissions: - if trans_type: - defaults = get_default_translation(trans_type, "transmission") - add_translation( - trans_type, - "transmission", - defaults.get("en") or trans_type, - defaults.get("mn") or trans_type, - defaults.get("ru") or trans_type - ) - - # Extract unique colors - colors = db.query(Car.color).distinct().filter(Car.color.isnot(None)).all() - for (color,) in colors: - if color: - defaults = get_default_translation(color, "color") - add_translation( - color, - "color", - defaults.get("en") or color, - defaults.get("mn") or color, - defaults.get("ru") or color - ) - - # Extract unique car_names and auto-translate - car_names = db.query(Car.car_name).distinct().filter(Car.car_name.isnot(None)).all() - for (car_name,) in car_names: - if car_name: - key = (car_name, "car_name") - if key not in added_keys: - existing = db.query(Translation).filter( - Translation.source_text == car_name, - Translation.category == "car_name" - ).first() - if not existing: - # Auto-translate car name by translating each component - translated_en = translate_car_name(car_name, "en", db) - translated_mn = translate_car_name(car_name, "mn", db) - translated_ru = translate_car_name(car_name, "ru", db) - - trans = Translation( - source_text=car_name, - category="car_name", - text_en=translated_en, - text_mn=translated_mn, - text_ru=translated_ru - ) - db.add(trans) - added_keys.add(key) - added_count += 1 - - db.commit() - - return {"message": f"Added {added_count} new translation entries with default translations"} - - -@router.post("/fill-defaults") -def fill_default_translations(db: Session = Depends(get_db)): - """ - Fill in missing translations with default values from the built-in dictionary. - This updates existing entries that have null translations. - """ - updated_count = 0 - - # Get all translations that have at least one null translation - translations = db.query(Translation).filter( - (Translation.text_en.is_(None)) | - (Translation.text_mn.is_(None)) | - (Translation.text_ru.is_(None)) - ).all() - - for trans in translations: - defaults = get_default_translation(trans.source_text, trans.category) - updated = False - - if not trans.text_en and defaults.get("en"): - trans.text_en = defaults["en"] - updated = True - elif not trans.text_en: - # Use source text as fallback for non-Korean languages - trans.text_en = trans.source_text - updated = True - - if not trans.text_mn and defaults.get("mn"): - trans.text_mn = defaults["mn"] - updated = True - elif not trans.text_mn: - trans.text_mn = trans.source_text - updated = True - - if not trans.text_ru and defaults.get("ru"): - trans.text_ru = defaults["ru"] - updated = True - elif not trans.text_ru: - trans.text_ru = trans.source_text - updated = True - - if updated: - updated_count += 1 - - db.commit() - - return {"message": f"Updated {updated_count} translations with default values"} - - -@router.post("/seed-all-defaults") -def seed_all_default_translations(db: Session = Depends(get_db)): - """ - Seed ALL translations from the DEFAULT_TRANSLATIONS dictionary into the database. - This pre-populates translations for all known terms, not just those in the cars table. - """ - added_count = 0 - skipped_count = 0 - - for category, terms in DEFAULT_TRANSLATIONS.items(): - for korean_text, translations in terms.items(): - # Check if already exists - existing = db.query(Translation).filter( - Translation.source_text == korean_text, - Translation.category == category - ).first() - - if existing: - skipped_count += 1 - continue - - trans = Translation( - source_text=korean_text, - category=category, - text_en=translations.get("en", korean_text), - text_mn=translations.get("mn", korean_text), - text_ru=translations.get("ru", korean_text) - ) - db.add(trans) - added_count += 1 - - db.commit() - - return { - "message": f"Seeded {added_count} translations from default dictionary (skipped {skipped_count} existing)", - "added": added_count, - "skipped": skipped_count, - "categories": list(DEFAULT_TRANSLATIONS.keys()) - } - - -# AI Auto-Translation Service -import httpx -import json -from typing import Dict, List -import asyncio - -# Translation service configuration -TRANSLATION_SERVICE = { - "enabled": True, - "provider": "google", # Options: "google", "deepl", "libre" - "api_key": None, # Set via environment variable - "libre_url": "https://libretranslate.com/translate", # Free option -} - - -async def translate_text_ai(text: str, source_lang: str, target_lang: str) -> str: - """ - Translate text using AI translation service. - Falls back to original text if translation fails. - """ - if not text or not TRANSLATION_SERVICE["enabled"]: - return text - - # Language code mapping - lang_map = { - "ko": "ko", - "en": "en", - "mn": "mn", - "ru": "ru", - } - - source = lang_map.get(source_lang, source_lang) - target = lang_map.get(target_lang, target_lang) - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - # Using LibreTranslate (free option) - response = await client.post( - TRANSLATION_SERVICE["libre_url"], - json={ - "q": text, - "source": source, - "target": target, - "format": "text" - }, - headers={"Content-Type": "application/json"} - ) - - if response.status_code == 200: - result = response.json() - return result.get("translatedText", text) - else: - return text - except Exception as e: - print(f"Translation error: {e}") - return text - - -def translate_text_sync(text: str, source_lang: str, target_lang: str) -> str: - """Synchronous wrapper for AI translation""" - try: - return asyncio.run(translate_text_ai(text, source_lang, target_lang)) - except RuntimeError: - # Already in an event loop - loop = asyncio.get_event_loop() - if loop.is_running(): - # Can't run async in sync context when loop is running - return text - return loop.run_until_complete(translate_text_ai(text, source_lang, target_lang)) - - -from pydantic import BaseModel -from typing import List as PyList - -class AutoTranslateRequest(BaseModel): - target_langs: PyList[str] = ["en", "mn", "ru"] - -@router.post("/auto-translate/{translation_id}") -def auto_translate_single( - translation_id: int, - request: AutoTranslateRequest = None, - db: Session = Depends(get_db) -): - """ - Auto-translate a single translation entry using AI. - Translates Korean source text to all target languages. - """ - if request is None: - request = AutoTranslateRequest() - - translation = db.query(Translation).filter(Translation.id == translation_id).first() - if not translation: - raise HTTPException(status_code=404, detail="Translation not found") - - source_text = translation.source_text - translations_result = {} - - # First check defaults - defaults = get_default_translation(source_text, translation.category) - - # Try to translate to requested languages - if "en" in request.target_langs: - if not translation.text_en or translation.text_en == source_text: - if defaults.get("en"): - translation.text_en = defaults["en"] - else: - translated = translate_text_sync(source_text, "ko", "en") - if translated != source_text: - translation.text_en = translated - translations_result["en"] = translation.text_en or "" - - if "mn" in request.target_langs: - if not translation.text_mn or translation.text_mn == source_text: - if defaults.get("mn"): - translation.text_mn = defaults["mn"] - else: - translated = translate_text_sync(source_text, "ko", "mn") - if translated != source_text: - translation.text_mn = translated - translations_result["mn"] = translation.text_mn or "" - - if "ru" in request.target_langs: - if not translation.text_ru or translation.text_ru == source_text: - if defaults.get("ru"): - translation.text_ru = defaults["ru"] - else: - translated = translate_text_sync(source_text, "ko", "ru") - if translated != source_text: - translation.text_ru = translated - translations_result["ru"] = translation.text_ru or "" - - db.commit() - db.refresh(translation) - - return { - "id": translation.id, - "source_text": translation.source_text, - "translations": translations_result, - "message": f"Auto-translated to: {', '.join(request.target_langs)}" - } - - -class BatchTranslateRequest(BaseModel): - target_langs: PyList[str] = ["en", "mn", "ru"] - category: Optional[str] = None - overwrite_existing: bool = False - -@router.post("/auto-translate-batch") -def auto_translate_batch( - request: BatchTranslateRequest = None, - limit: int = Query(100, ge=1, le=500), - db: Session = Depends(get_db) -): - """ - Auto-translate multiple translations that have missing translations. - Limits batch size to avoid API rate limits. - """ - if request is None: - request = BatchTranslateRequest() - - # Build query based on options - if request.overwrite_existing: - # Get all translations (optionally filtered by category) - query = db.query(Translation) - else: - # Only get translations with missing values - query = db.query(Translation).filter( - (Translation.text_en.is_(None)) | - (Translation.text_mn.is_(None)) | - (Translation.text_ru.is_(None)) | - (Translation.text_en == Translation.source_text) | - (Translation.text_mn == Translation.source_text) | - (Translation.text_ru == Translation.source_text) | - (Translation.text_en == "") | - (Translation.text_mn == "") | - (Translation.text_ru == "") - ) - - if request.category: - query = query.filter(Translation.category == request.category) - - translations = query.limit(limit).all() - - successful = 0 - failed = 0 - results = [] - - for trans in translations: - source_text = trans.source_text - try: - updated = False - - # First check if we have default translations - defaults = get_default_translation(source_text, trans.category) - - # English - if "en" in request.target_langs: - if request.overwrite_existing or not trans.text_en or trans.text_en == source_text or trans.text_en == "": - if defaults.get("en"): - trans.text_en = defaults["en"] - updated = True - else: - translated = translate_text_sync(source_text, "ko", "en") - if translated and translated != source_text: - trans.text_en = translated - updated = True - - # Mongolian - if "mn" in request.target_langs: - if request.overwrite_existing or not trans.text_mn or trans.text_mn == source_text or trans.text_mn == "": - if defaults.get("mn"): - trans.text_mn = defaults["mn"] - updated = True - else: - translated = translate_text_sync(source_text, "ko", "mn") - if translated and translated != source_text: - trans.text_mn = translated - updated = True - - # Russian - if "ru" in request.target_langs: - if request.overwrite_existing or not trans.text_ru or trans.text_ru == source_text or trans.text_ru == "": - if defaults.get("ru"): - trans.text_ru = defaults["ru"] - updated = True - else: - translated = translate_text_sync(source_text, "ko", "ru") - if translated and translated != source_text: - trans.text_ru = translated - updated = True - - if updated: - successful += 1 - results.append({ - "id": trans.id, - "source_text": source_text, - "success": True - }) - else: - results.append({ - "id": trans.id, - "source_text": source_text, - "success": True, - "error": "No changes needed" - }) - - except Exception as e: - failed += 1 - results.append({ - "id": trans.id, - "source_text": source_text, - "success": False, - "error": str(e) - }) - - db.commit() - - return { - "total_processed": len(translations), - "successful": successful, - "failed": failed, - "results": results - } - - -class TranslateOnDemandRequest(BaseModel): - text: str - source_lang: str = "ko" - target_lang: str = "en" - -@router.post("/translate-on-demand") -def translate_on_demand( - request: TranslateOnDemandRequest, - db: Session = Depends(get_db) -): - """ - Translate a single text on-demand without saving to database. - Useful for dynamic content translation. - """ - text = request.text - target_lang = request.target_lang - - if not text: - return {"source_text": "", "translated_text": "", "source_lang": request.source_lang, "target_lang": target_lang} - - translated_text = text - - # First, check existing translations in DB - trans = db.query(Translation).filter( - Translation.source_text == text - ).first() - - if trans: - lang_field = f"text_{target_lang}" - translated = getattr(trans, lang_field, None) - if translated and translated != text: - translated_text = translated - else: - # Check default dictionary - for category in DEFAULT_TRANSLATIONS: - if text in DEFAULT_TRANSLATIONS[category]: - if target_lang in DEFAULT_TRANSLATIONS[category][text]: - translated_text = DEFAULT_TRANSLATIONS[category][text][target_lang] - break - - # If still not found, try AI translation - if translated_text == text: - translated_text = translate_text_sync(text, request.source_lang, target_lang) - - return { - "source_text": text, - "translated_text": translated_text, - "source_lang": request.source_lang, - "target_lang": target_lang - } - - -@router.get("/stats") -def get_translation_stats(db: Session = Depends(get_db)): - """Get translation statistics""" - total = db.query(Translation).count() - - # Count by category - from sqlalchemy import func - by_category = db.query( - Translation.category, - func.count(Translation.id) - ).group_by(Translation.category).all() - - # Count missing translations (null or same as source) - missing_en = db.query(Translation).filter( - (Translation.text_en.is_(None)) | - (Translation.text_en == Translation.source_text) | - (Translation.text_en == "") - ).count() - - missing_mn = db.query(Translation).filter( - (Translation.text_mn.is_(None)) | - (Translation.text_mn == Translation.source_text) | - (Translation.text_mn == "") - ).count() - - missing_ru = db.query(Translation).filter( - (Translation.text_ru.is_(None)) | - (Translation.text_ru == Translation.source_text) | - (Translation.text_ru == "") - ).count() - - translated_en = total - missing_en - translated_mn = total - missing_mn - translated_ru = total - missing_ru - - return { - "total_entries": total, - "by_category": {cat: count for cat, count in by_category}, - "translation_coverage": { - "english": { - "translated": translated_en, - "total": total, - "percentage": round(translated_en / total * 100, 1) if total > 0 else 0 - }, - "mongolian": { - "translated": translated_mn, - "total": total, - "percentage": round(translated_mn / total * 100, 1) if total > 0 else 0 - }, - "russian": { - "translated": translated_ru, - "total": total, - "percentage": round(translated_ru / total * 100, 1) if total > 0 else 0 - } - } - } diff --git a/backend/app/main.py b/backend/app/main.py index cc4bafa..7d78448 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ import asyncio from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from .database import engine, Base, SessionLocal -from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews +from .api import cars, auth, inquiries, hero_banners, carmodoo, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews from .config import get_settings from .services.exchange_rate_service import update_exchange_rates from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs @@ -223,7 +223,6 @@ app.include_router(auth.router, prefix="/api") app.include_router(inquiries.router, prefix="/api") app.include_router(hero_banners.router, prefix="/api") app.include_router(carmodoo.router, prefix="/api") -app.include_router(translations.router, prefix="/api") app.include_router(cc.router, prefix="/api") app.include_router(settings.router, prefix="/api") app.include_router(vehicle_requests.router, prefix="/api") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2fd895b..2dc68b6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,6 @@ from .car import CarMaker, CarModel, Car, CarImage, CarOption from .user import User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode from .inquiry import Inquiry, InquiryMessage, InquiryStatus, InquiryCategory from .hero_banner import HeroBanner, HeroBannerSettings -from .translation import Translation from .cache import CarCache, CarDetailCache, CacheRequestQueue from .settings import SystemSettings from .vehicle_request import VehicleRequest, RequestVehicle, PurchasedVehicle @@ -40,7 +39,6 @@ __all__ = [ "InquiryCategory", "HeroBanner", "HeroBannerSettings", - "Translation", "CarCache", "CarDetailCache", "CacheRequestQueue", diff --git a/backend/app/models/translation.py b/backend/app/models/translation.py deleted file mode 100644 index 1fffb10..0000000 --- a/backend/app/models/translation.py +++ /dev/null @@ -1,28 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Index -from sqlalchemy.sql import func -from ..database import Base - - -class Translation(Base): - """Translation dictionary for car-related terms""" - __tablename__ = "translations" - - id = Column(Integer, primary_key=True, index=True) - - # Source text (Korean) - source_text = Column(String(500), nullable=False, index=True) - - # Category: maker, model, fuel, transmission, color, car_name, etc. - category = Column(String(50), nullable=False, index=True) - - # Translations - text_en = Column(String(500)) # English - text_mn = Column(String(500)) # Mongolian - text_ru = Column(String(500)) # Russian - - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - __table_args__ = ( - Index('ix_translations_source_category', 'source_text', 'category', unique=True), - ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 5fac6f8..022f358 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -16,10 +16,6 @@ from .hero_banner import ( HeroBannerListResponse, HeroBannerLocalizedResponse, HeroBannerSettingsUpdate, HeroBannerSettingsResponse, ) -from .translation import ( - TranslationCreate, TranslationUpdate, TranslationResponse, - TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse, -) from .vehicle_request import ( VehicleRequestCreate, VehicleRequestResponse, RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove, @@ -65,8 +61,6 @@ __all__ = [ "HeroBannerCreate", "HeroBannerUpdate", "HeroBannerResponse", "HeroBannerListResponse", "HeroBannerLocalizedResponse", "HeroBannerSettingsUpdate", "HeroBannerSettingsResponse", - "TranslationCreate", "TranslationUpdate", "TranslationResponse", - "TranslationListResponse", "TranslationBulkRequest", "TranslationBulkResponse", "VehicleRequestCreate", "VehicleRequestResponse", "RequestVehicleCreate", "RequestVehicleResponse", "RequestVehicleApprove", "PurchasedVehicleCreate", "PurchasedVehicleResponse", "PurchasedVehicleUpdateStatus", diff --git a/backend/app/schemas/translation.py b/backend/app/schemas/translation.py deleted file mode 100644 index 1a4373d..0000000 --- a/backend/app/schemas/translation.py +++ /dev/null @@ -1,52 +0,0 @@ -from pydantic import BaseModel -from typing import Optional, List -from datetime import datetime - - -class TranslationCreate(BaseModel): - source_text: str - category: str - text_en: Optional[str] = None - text_mn: Optional[str] = None - text_ru: Optional[str] = None - - -class TranslationUpdate(BaseModel): - source_text: Optional[str] = None - category: Optional[str] = None - text_en: Optional[str] = None - text_mn: Optional[str] = None - text_ru: Optional[str] = None - - -class TranslationResponse(BaseModel): - id: int - source_text: str - category: str - text_en: Optional[str] = None - text_mn: Optional[str] = None - text_ru: Optional[str] = None - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class TranslationListResponse(BaseModel): - total: int - page: int - page_size: int - translations: List[TranslationResponse] - - -class TranslationBulkRequest(BaseModel): - """Bulk translation lookup request""" - texts: List[str] - category: Optional[str] = None - lang: str = "en" - - -class TranslationBulkResponse(BaseModel): - """Returns a dictionary mapping source text to translated text""" - translations: dict # {source_text: translated_text} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 4e9301b..2006fde 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -16,7 +16,6 @@ const menuItems = [ { href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' }, { href: '/admin/sns-shares', label: 'SNS Shares', icon: '📢' }, { href: '/admin/notifications', label: 'Notifications', icon: '🔔' }, - { href: '/admin/translations', label: 'Translations', icon: '🌐' }, { href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' }, { href: '/admin/users', label: 'Users', icon: '👥' }, { href: '/admin/inquiries', label: 'Inquiries', icon: '💬' }, diff --git a/frontend/src/app/admin/translations/page.tsx b/frontend/src/app/admin/translations/page.tsx deleted file mode 100644 index 35c6f8f..0000000 --- a/frontend/src/app/admin/translations/page.tsx +++ /dev/null @@ -1,765 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { translationsApi, Translation, TranslationListResponse } from '@/lib/api'; - -const CATEGORY_LABELS: Record = { - maker: 'Maker (제조사)', - model: 'Model (모델)', - fuel: 'Fuel (연료)', - transmission: 'Transmission (변속기)', - color: 'Color (색상)', - car_name: 'Car Name (차량명)', - general: 'General (일반)', -}; - -interface TranslationStats { - total_entries: number; - by_category: Record; - translation_coverage: { - english: { translated: number; total: number; percentage: number }; - mongolian: { translated: number; total: number; percentage: number }; - russian: { translated: number; total: number; percentage: number }; - }; -} - -export default function TranslationsPage() { - const [translations, setTranslations] = useState([]); - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(true); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [selectedCategory, setSelectedCategory] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [editingId, setEditingId] = useState(null); - const [editData, setEditData] = useState>({}); - const [showAddModal, setShowAddModal] = useState(false); - const [showBatchModal, setShowBatchModal] = useState(false); - const [translatingId, setTranslatingId] = useState(null); - const [batchTranslating, setBatchTranslating] = useState(false); - const [stats, setStats] = useState(null); - const [batchOptions, setBatchOptions] = useState({ - category: '', - overwriteExisting: false, - targetLangs: ['en', 'mn', 'ru'] as string[], - }); - const [batchResult, setBatchResult] = useState<{ - total_processed: number; - successful: number; - failed: number; - } | null>(null); - const [newTranslation, setNewTranslation] = useState({ - source_text: '', - category: 'general', - text_en: '', - text_mn: '', - text_ru: '', - }); - - const pageSize = 20; - - useEffect(() => { - loadCategories(); - loadStats(); - }, []); - - useEffect(() => { - loadTranslations(); - }, [page, selectedCategory, searchTerm]); - - const loadCategories = async () => { - try { - const data = await translationsApi.getCategories(); - setCategories(data); - } catch (err) { - console.error('Failed to load categories:', err); - } - }; - - const loadStats = async () => { - try { - const data = await translationsApi.getStats(); - setStats(data); - } catch (err) { - console.error('Failed to load stats:', err); - } - }; - - const loadTranslations = async () => { - setLoading(true); - try { - const data = await translationsApi.getList({ - page, - page_size: pageSize, - category: selectedCategory || undefined, - search: searchTerm || undefined, - }); - setTranslations(data.translations); - setTotal(data.total); - } catch (err) { - console.error('Failed to load translations:', err); - } finally { - setLoading(false); - } - }; - - const handleAutoExtract = async () => { - try { - const result = await translationsApi.autoExtract(); - alert(result.message); - loadTranslations(); - loadStats(); - } catch (err) { - console.error('Failed to auto-extract:', err); - alert('Failed to auto-extract translations'); - } - }; - - const handleSeedAllDefaults = async () => { - try { - const result = await translationsApi.seedAllDefaults(); - alert(`${result.message}\n\nCategories: ${result.categories.join(', ')}`); - loadTranslations(); - loadStats(); - } catch (err) { - console.error('Failed to seed defaults:', err); - alert('Failed to seed default translations'); - } - }; - - const handleAutoTranslate = async (translation: Translation) => { - setTranslatingId(translation.id); - try { - const result = await translationsApi.autoTranslate(translation.id); - alert(`Auto-translated: ${result.message}`); - loadTranslations(); - loadStats(); - } catch (err: any) { - console.error('Failed to auto-translate:', err); - alert(err.response?.data?.detail || 'Failed to auto-translate'); - } finally { - setTranslatingId(null); - } - }; - - const handleBatchTranslate = async () => { - setBatchTranslating(true); - setBatchResult(null); - try { - const result = await translationsApi.autoTranslateBatch( - batchOptions.targetLangs, - batchOptions.category || undefined, - batchOptions.overwriteExisting - ); - setBatchResult({ - total_processed: result.total_processed, - successful: result.successful, - failed: result.failed, - }); - loadTranslations(); - loadStats(); - } catch (err: any) { - console.error('Failed to batch translate:', err); - alert(err.response?.data?.detail || 'Failed to batch translate'); - } finally { - setBatchTranslating(false); - } - }; - - const handleEdit = (translation: Translation) => { - setEditingId(translation.id); - setEditData({ - text_en: translation.text_en || '', - text_mn: translation.text_mn || '', - text_ru: translation.text_ru || '', - }); - }; - - const handleSave = async (id: number) => { - try { - await translationsApi.update(id, editData); - setEditingId(null); - loadTranslations(); - loadStats(); - } catch (err) { - console.error('Failed to save:', err); - alert('Failed to save translation'); - } - }; - - const handleDelete = async (id: number) => { - if (!confirm('Delete this translation?')) return; - try { - await translationsApi.delete(id); - loadTranslations(); - loadStats(); - } catch (err) { - console.error('Failed to delete:', err); - alert('Failed to delete translation'); - } - }; - - const handleAdd = async () => { - if (!newTranslation.source_text.trim()) { - alert('Source text is required'); - return; - } - try { - await translationsApi.create(newTranslation); - setShowAddModal(false); - setNewTranslation({ - source_text: '', - category: 'general', - text_en: '', - text_mn: '', - text_ru: '', - }); - loadTranslations(); - loadStats(); - } catch (err: any) { - console.error('Failed to add:', err); - alert(err.response?.data?.detail || 'Failed to add translation'); - } - }; - - const totalPages = Math.ceil(total / pageSize); - - return ( -
-
-

Translations Management

-
- - - - -
-
- - {/* Translation Statistics */} - {stats && ( -
-
-
Total Entries
-
{stats.total_entries}
-
-
-
English Coverage
-
-
{stats.translation_coverage.english.percentage.toFixed(1)}%
-
({stats.translation_coverage.english.translated}/{stats.translation_coverage.english.total})
-
-
-
-
-
-
-
Mongolian Coverage
-
-
{stats.translation_coverage.mongolian.percentage.toFixed(1)}%
-
({stats.translation_coverage.mongolian.translated}/{stats.translation_coverage.mongolian.total})
-
-
-
-
-
-
-
Russian Coverage
-
-
{stats.translation_coverage.russian.percentage.toFixed(1)}%
-
({stats.translation_coverage.russian.translated}/{stats.translation_coverage.russian.total})
-
-
-
-
-
-
- )} - - {/* Category Stats */} - {stats && Object.keys(stats.by_category).length > 0 && ( -
-

Entries by Category

-
- {Object.entries(stats.by_category).map(([cat, count]) => ( - - {CATEGORY_LABELS[cat] || cat}: {count} - - ))} -
-
- )} - - {/* Filters */} -
-
-
- - { - setSearchTerm(e.target.value); - setPage(1); - }} - placeholder="Search translations..." - className="w-full border border-gray-300 rounded-lg px-3 py-2" - /> -
-
- - -
-
-
- - {/* Table */} -
-
- - - - - - - - - - - - - {loading ? ( - - - - ) : translations.length === 0 ? ( - - - - ) : ( - translations.map((trans) => ( - - - - - - - - - )) - )} - -
CategoryKorean (Source)EnglishMongolianRussianActions
-
-
-
-
- No translations found -
- - {CATEGORY_LABELS[trans.category] || trans.category} - - {trans.source_text} - {editingId === trans.id ? ( - setEditData({ ...editData, text_en: e.target.value })} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> - ) : ( - - {trans.text_en || 'Not set'} - - )} - - {editingId === trans.id ? ( - setEditData({ ...editData, text_mn: e.target.value })} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> - ) : ( - - {trans.text_mn || 'Not set'} - - )} - - {editingId === trans.id ? ( - setEditData({ ...editData, text_ru: e.target.value })} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> - ) : ( - - {trans.text_ru || 'Not set'} - - )} - - {editingId === trans.id ? ( -
- - -
- ) : ( -
- - - -
- )} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {page} of {totalPages} ({total} total) - - -
- )} -
- - {/* Add Modal */} - {showAddModal && ( -
-
-

Add Translation

- -
-
- - setNewTranslation({ ...newTranslation, source_text: e.target.value })} - className="w-full border border-gray-300 rounded-lg px-3 py-2" - placeholder="Enter Korean text" - /> -
- -
- - -
- -
- - setNewTranslation({ ...newTranslation, text_en: e.target.value })} - className="w-full border border-gray-300 rounded-lg px-3 py-2" - placeholder="English translation" - /> -
- -
- - setNewTranslation({ ...newTranslation, text_mn: e.target.value })} - className="w-full border border-gray-300 rounded-lg px-3 py-2" - placeholder="Mongolian translation" - /> -
- -
- - setNewTranslation({ ...newTranslation, text_ru: e.target.value })} - className="w-full border border-gray-300 rounded-lg px-3 py-2" - placeholder="Russian translation" - /> -
-
- -
- - -
-
-
- )} - - {/* Batch Translate Modal */} - {showBatchModal && ( -
-
-

Batch Auto-Translate

- -
-
- - -

Leave empty to translate all categories

-
- -
- -
- - - -
-
- -
- -

If unchecked, only empty translations will be filled

-
- - {batchResult && ( -
-

Translation Results

-
-
-
{batchResult.total_processed}
-
Processed
-
-
-
{batchResult.successful}
-
Successful
-
-
-
{batchResult.failed}
-
Failed
-
-
-
- )} -
- -
- - -
-
-
- )} -
- ); -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 68356aa..e8da48f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -235,146 +235,6 @@ export const heroBannersApi = { }, }; -// Translations API -export interface Translation { - id: number; - source_text: string; - category: string; - text_en?: string; - text_mn?: string; - text_ru?: string; - created_at: string; - updated_at: string; -} - -export interface TranslationListResponse { - total: number; - page: number; - page_size: number; - translations: Translation[]; -} - -export const translationsApi = { - getCategories: async (): Promise => { - const { data } = await api.get('/translations/categories'); - return data; - }, - - getList: async (params: { - page?: number; - page_size?: number; - category?: string; - search?: string; - }): Promise => { - const { data } = await api.get('/translations', { params }); - return data; - }, - - getById: async (id: number): Promise => { - const { data } = await api.get(`/translations/${id}`); - return data; - }, - - create: async (translationData: { - source_text: string; - category: string; - text_en?: string; - text_mn?: string; - text_ru?: string; - }): Promise => { - const { data } = await api.post('/translations', translationData); - return data; - }, - - update: async (id: number, translationData: { - source_text?: string; - category?: string; - text_en?: string; - text_mn?: string; - text_ru?: string; - }): Promise => { - const { data } = await api.put(`/translations/${id}`, translationData); - return data; - }, - - delete: async (id: number): Promise => { - await api.delete(`/translations/${id}`); - }, - - autoExtract: async (): Promise<{ message: string }> => { - const { data } = await api.post('/translations/auto-extract'); - return data; - }, - - seedAllDefaults: async (): Promise<{ message: string; added: number; skipped: number; categories: string[] }> => { - const { data } = await api.post('/translations/seed-all-defaults'); - return data; - }, - - bulkLookup: async (texts: string[], lang: string, category?: string): Promise<{ translations: Record }> => { - const { data } = await api.post('/translations/bulk-lookup', { - texts, - lang, - category, - }); - return data; - }, - - // Auto-translation endpoints - autoTranslate: async (translationId: number, targetLangs?: string[]): Promise<{ - id: number; - source_text: string; - translations: Record; - message: string; - }> => { - const { data } = await api.post(`/translations/auto-translate/${translationId}`, { - target_langs: targetLangs || ['en', 'mn', 'ru'] - }); - return data; - }, - - autoTranslateBatch: async (targetLangs?: string[], category?: string, overwriteExisting?: boolean): Promise<{ - total_processed: number; - successful: number; - failed: number; - results: Array<{ id: number; source_text: string; success: boolean; error?: string }>; - }> => { - const { data } = await api.post('/translations/auto-translate-batch', { - target_langs: targetLangs || ['en', 'mn', 'ru'], - category, - overwrite_existing: overwriteExisting || false - }); - return data; - }, - - translateOnDemand: async (text: string, sourceLang: string, targetLang: string): Promise<{ - source_text: string; - translated_text: string; - source_lang: string; - target_lang: string; - }> => { - const { data } = await api.post('/translations/translate-on-demand', { - text, - source_lang: sourceLang, - target_lang: targetLang - }); - return data; - }, - - getStats: async (): Promise<{ - total_entries: number; - by_category: Record; - translation_coverage: { - english: { translated: number; total: number; percentage: number }; - mongolian: { translated: number; total: number; percentage: number }; - russian: { translated: number; total: number; percentage: number }; - }; - }> => { - const { data } = await api.get('/translations/stats'); - return data; - }, -}; - // Carmodoo API export interface CarmodooMaker { code: string; diff --git a/frontend/src/lib/useTranslate.ts b/frontend/src/lib/useTranslate.ts index b4b8a66..c2593a0 100644 --- a/frontend/src/lib/useTranslate.ts +++ b/frontend/src/lib/useTranslate.ts @@ -1,74 +1,16 @@ -import { useState, useEffect, useCallback } from 'react'; -import { translationsApi } from './api'; +import { useCallback } from 'react'; import { useLanguageStore, translateCarName, Language } from './i18n'; -// Cache for translations to avoid repeated API calls -const translationCache: Record> = {}; - export function useTranslate() { const { language } = useLanguageStore(); - const [translations, setTranslations] = useState>({}); - const [loading, setLoading] = useState(false); - // Get cache key for current language - const cacheKey = `trans_${language}`; - - // Load translations from cache on mount - useEffect(() => { - if (translationCache[cacheKey]) { - setTranslations(translationCache[cacheKey]); - } - }, [cacheKey]); - - // Translate a single text + // Translate a single text using static dictionary const translate = useCallback((text: string | undefined | null): string => { if (!text) return ''; if (language === 'ko') return text; // Korean is source, no translation needed - // Try static translations FIRST (for fuel, transmission, car names, etc.) - const staticTranslation = translateCarName(text, language as Language); - if (staticTranslation !== text) { - return staticTranslation; - } - - // Then check API cache for other translations - const cached = translationCache[cacheKey]?.[text]; - if (cached) return cached; - - return text; // Fallback to original if no translation found - }, [language, cacheKey]); - - // Bulk load translations for multiple texts - const loadTranslations = useCallback(async (texts: string[], category?: string) => { - if (language === 'ko') return; // No need to translate Korean - - // Filter out already cached texts - const uncachedTexts = texts.filter( - t => t && !translationCache[cacheKey]?.[t] - ); - - if (uncachedTexts.length === 0) return; - - setLoading(true); - try { - // Map language code to API expected format - const langCode = language === 'mn' ? 'mn' : language === 'ru' ? 'ru' : 'en'; - - const result = await translationsApi.bulkLookup(uncachedTexts, langCode, category); - - // Update cache - if (!translationCache[cacheKey]) { - translationCache[cacheKey] = {}; - } - - Object.assign(translationCache[cacheKey], result.translations); - setTranslations({ ...translationCache[cacheKey] }); - } catch (err) { - console.error('Failed to load translations:', err); - } finally { - setLoading(false); - } - }, [language, cacheKey]); + return translateCarName(text, language as Language); + }, [language]); // Translate car object fields const translateCar = useCallback((car: { @@ -89,8 +31,8 @@ export function useTranslate() { }; }, [translate]); - // Preload translations for a list of cars - const preloadCarTranslations = useCallback(async (cars: Array<{ + // Kept for API compatibility - static translations are synchronous, so this is a no-op + const preloadCarTranslations = useCallback(async (_cars: Array<{ car_name?: string; fuel?: string; transmission?: string; @@ -98,37 +40,13 @@ export function useTranslate() { maker?: { name: string }; model?: { name: string }; }>) => { - const textsToTranslate: string[] = []; - - cars.forEach(car => { - if (car.car_name) textsToTranslate.push(car.car_name); - if (car.fuel) textsToTranslate.push(car.fuel); - if (car.transmission) textsToTranslate.push(car.transmission); - if (car.color) textsToTranslate.push(car.color); - if (car.maker?.name) textsToTranslate.push(car.maker.name); - if (car.model?.name) textsToTranslate.push(car.model.name); - }); - - // Remove duplicates - const uniqueTexts = Array.from(new Set(textsToTranslate)); - - if (uniqueTexts.length > 0) { - await loadTranslations(uniqueTexts); - } - }, [loadTranslations]); + // No-op: static dictionary translations are synchronous + }, []); return { translate, translateCar, - loadTranslations, preloadCarTranslations, - loading, + loading: false, }; } - -// Clear translation cache (useful when translations are updated) -export function clearTranslationCache() { - Object.keys(translationCache).forEach(key => { - delete translationCache[key]; - }); -}