diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 3e392bb..60ca893 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -212,64 +212,74 @@ def _backfill_row(row, en_fields: dict) -> int: def backfill_en_content() -> None: """پر کردن ترجمه‌های انگلیسی سطرهای موجود از seed_data_en — idempotent. - تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ جدول‌های دارای کلید طبیعی (slug/key/ - page_key) با همان کلید؛ بقیه فقط وقتی سطرها با seed دست‌نخورده‌اند (تعداد و - idها مطابق) و به‌ترتیب. هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود. + تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی + (مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را + ویرایش کرده باشد سطر تطبیق نمی‌خورد و ترجمه‌اش خالی می‌ماند تا از پنل پر شود. + هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود. """ from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقه‌ای - # (مدل، فهرست فارسی، فهرست انگلیسی، کلید تطبیق یا None برای ترتیبی) - PLAN: list[tuple[object, list[dict], list[dict], str | None]] = [ - (models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], None), - (models.Hero, [seed_data.HERO], [seed_data_en.HERO], None), - (models.StorySection, [seed_data.STORY], [seed_data_en.STORY], None), - (models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, "key"), - (models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, "key"), - (models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, None), - (models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, None), - (models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, None), - (models.Office, seed_data.OFFICES, seed_data_en.OFFICES, None), - (models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, None), - (models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, "key"), - (models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], None), - (models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], None), - (models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], None), - (models.Service, seed_data.SERVICES, seed_data_en.SERVICES, None), - (models.Stat, seed_data.STATS, seed_data_en.STATS, None), - (models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, "slug"), - (models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, "slug"), - (models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, None), - (models.Faq, seed_data.FAQS, seed_data_en.FAQS, None), - (models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, None), - (models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, None), - (models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, None), - (models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], None), - (models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, "page_key"), + # (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تک‌سطری‌ها) + PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [ + (models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()), + (models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()), + (models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()), + (models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")), + (models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")), + (models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")), + (models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)), + (models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)), + (models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)), + (models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)), + (models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)), + (models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()), + (models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()), + (models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()), + (models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)), + (models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)), + (models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")), + (models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")), + (models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)), + (models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)), + (models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)), + (models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)), + (models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)), + (models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()), + (models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)), ] db = SessionLocal() try: total_rows = 0 total_fields = 0 - skipped: list[str] = [] - for model, fa_rows, en_rows, match_key in PLAN: + unmatched: list[str] = [] + for model, fa_rows, en_rows, match_keys in PLAN: rows = db.query(model).all() if not rows or not en_rows: continue + table_name = getattr(model, "__tablename__", str(model)) - if match_key is not None: - en_by_key = {r[match_key]: r for r in en_rows if match_key in r} - matched = [(row, en_by_key.get(getattr(row, match_key, None))) for row in rows] - pairs = [(row, en) for row, en in matched if en is not None] - elif len(rows) == 1 and len(en_rows) == 1: + if not match_keys: + if len(rows) != 1 or len(en_rows) != 1: + unmatched.append(table_name) + continue pairs = [(rows[0], en_rows[0])] else: - # تطبیق ترتیبی فقط برای جدول‌های دست‌نخورده — idها از ۱ پیوسته - ids = [row.id for row in rows] - if ids != list(range(1, len(rows) + 1)) or len(rows) != len(en_rows): - skipped.append(getattr(model, "__tablename__", str(model))) - continue - pairs = list(zip(rows, en_rows)) + # برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است) + en_lookups: list[dict[object, dict]] = [] + for key in match_keys: + en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None}) + pairs = [] + matched_en_ids: set[int] = set() + for row in rows: + en_row = None + for key, lookup in zip(match_keys, en_lookups): + en_row = lookup.get(getattr(row, key, None)) + if en_row is not None: + break + if en_row is not None and id(en_row) not in matched_en_ids: + matched_en_ids.add(id(en_row)) + pairs.append((row, en_row)) for row, en_row in pairs: # بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل می‌شود @@ -284,8 +294,10 @@ def backfill_en_content() -> None: db.commit() if total_fields: print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.") - if skipped: - print(f"جدول‌های تغییرکرده (بدون backfill خودکار — از پنل مدیریت ترجمه کنید): {', '.join(skipped)}") + if unmatched: + print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):") + for name in unmatched: + print(f" - {name}") finally: db.close() diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 4b4783a..3ad0baf 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -136,8 +136,23 @@ def _shell( ) if lang == "en": - # پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت و زبان سند عوض می‌شود + # پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود raw = re.sub(r']*>', '', raw, count=1) + raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND) + raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"') + # description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود + raw = re.sub(r']*/>\s*', '', raw, count=1) + # og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود + og_locale = "en_US" if lang == "en" else "fa_IR" + if re.search(r'.*?", f"{esc(title)}", raw, count=1, flags=re.S) raw = raw.replace("", f" {alternates}\n {block}\n ", 1) if noscript: @@ -223,7 +238,6 @@ def blog_list(db: Session = Depends(get_db)): title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db), description=meta.description if meta else "", path="/blog", - og_image=meta.image if meta else "", ) diff --git a/docker-compose.yml b/docker-compose.yml index 689ce70..27f2e42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: command: - --providers.docker=true - --providers.docker.exposedbydefault=false + # فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید + - --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`) - --entrypoints.web.address=:80 ports: - "${TRAEFIK_HTTP_PORT:-5173}:80" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd2be1e..3eb9337 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,20 +37,13 @@ const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [ { path: '/contact', element: }, ]; -function LocaleRoutes({ prefix }: { prefix: '' | '/en' }) { - return ( - <> - {PUBLIC_ROUTES.map(({ path, element }) => ( - - ))} - {prefix === '' && } />} - - ); -} +// فرزند مستقیم باید باشد — آرایه‌های Route به‌جای کامپوننت واسط +const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => ( + +)); +const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => ( + +)); function ScrollToTop() { const { pathname } = useLocation(); @@ -75,8 +68,9 @@ function AnimatedRoutes() { } /> } /> } /> - - + {FA_ROUTES} + } /> + {EN_ROUTES} ); diff --git a/frontend/src/pages/ArticleDetail.tsx b/frontend/src/pages/ArticleDetail.tsx index 8eebe3a..19f2389 100644 --- a/frontend/src/pages/ArticleDetail.tsx +++ b/frontend/src/pages/ArticleDetail.tsx @@ -40,7 +40,7 @@ export function ArticleDetail() { useEffect(() => { if (!article) return; applySeo({ - title: `${article.title} | ${brand}`, + title: `${article.title} | ${brand.name}`, description: article.excerpt, path: href(`/blog/${article.slug}`), image: article.image, diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 89e61c6..82d9adb 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -23,7 +23,7 @@ export function ProjectDetail() { useEffect(() => { if (!project) return; applySeo({ - title: `${project.title} | ${brand}`, + title: `${project.title} | ${brand.name}`, description: project.summary, path: href(`/projects/${project.slug}`), image: project.image,