اصلاحات بازبینی: تطبیق backfill با متن فارسی، رفع باگ متای /blog (ستون image ناموجود)، پاکسازی og:locale و برند در پوسته سئو، محدودکردن Traefik به پروژه خود، اصلاح مسیریابی Routes و عنوان برند در صفحات جزئیات
This commit is contained in:
+57
-45
@@ -212,64 +212,74 @@ def _backfill_row(row, en_fields: dict) -> int:
|
|||||||
def backfill_en_content() -> None:
|
def backfill_en_content() -> None:
|
||||||
"""پر کردن ترجمههای انگلیسی سطرهای موجود از seed_data_en — idempotent.
|
"""پر کردن ترجمههای انگلیسی سطرهای موجود از seed_data_en — idempotent.
|
||||||
|
|
||||||
تطبیق سطرها: جدولهای تکسطری مستقیم؛ جدولهای دارای کلید طبیعی (slug/key/
|
تطبیق سطرها: جدولهای تکسطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
|
||||||
page_key) با همان کلید؛ بقیه فقط وقتی سطرها با seed دستنخوردهاند (تعداد و
|
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
|
||||||
idها مطابق) و بهترتیب. هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود.
|
ویرایش کرده باشد سطر تطبیق نمیخورد و ترجمهاش خالی میماند تا از پنل پر شود.
|
||||||
|
هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود.
|
||||||
"""
|
"""
|
||||||
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای
|
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای
|
||||||
|
|
||||||
# (مدل، فهرست فارسی، فهرست انگلیسی، کلید تطبیق یا None برای ترتیبی)
|
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تکسطریها)
|
||||||
PLAN: list[tuple[object, list[dict], list[dict], str | None]] = [
|
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
|
||||||
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], None),
|
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
|
||||||
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], None),
|
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
|
||||||
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], None),
|
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
|
||||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, "key"),
|
(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"),
|
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
|
||||||
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, None),
|
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
|
||||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, None),
|
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
|
||||||
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, None),
|
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
|
||||||
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, None),
|
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
|
||||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, None),
|
(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.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
|
||||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], None),
|
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
|
||||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], None),
|
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
|
||||||
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], None),
|
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
|
||||||
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, None),
|
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
|
||||||
(models.Stat, seed_data.STATS, seed_data_en.STATS, None),
|
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
|
||||||
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, "slug"),
|
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
|
||||||
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, "slug"),
|
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
|
||||||
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, None),
|
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
|
||||||
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, None),
|
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
|
||||||
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, None),
|
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
|
||||||
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, None),
|
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
|
||||||
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, None),
|
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
|
||||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], None),
|
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
|
||||||
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, "page_key"),
|
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
|
||||||
]
|
]
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
total_rows = 0
|
total_rows = 0
|
||||||
total_fields = 0
|
total_fields = 0
|
||||||
skipped: list[str] = []
|
unmatched: list[str] = []
|
||||||
for model, fa_rows, en_rows, match_key in PLAN:
|
for model, fa_rows, en_rows, match_keys in PLAN:
|
||||||
rows = db.query(model).all()
|
rows = db.query(model).all()
|
||||||
if not rows or not en_rows:
|
if not rows or not en_rows:
|
||||||
continue
|
continue
|
||||||
|
table_name = getattr(model, "__tablename__", str(model))
|
||||||
|
|
||||||
if match_key is not None:
|
if not match_keys:
|
||||||
en_by_key = {r[match_key]: r for r in en_rows if match_key in r}
|
if len(rows) != 1 or len(en_rows) != 1:
|
||||||
matched = [(row, en_by_key.get(getattr(row, match_key, None))) for row in rows]
|
unmatched.append(table_name)
|
||||||
pairs = [(row, en) for row, en in matched if en is not None]
|
continue
|
||||||
elif len(rows) == 1 and len(en_rows) == 1:
|
|
||||||
pairs = [(rows[0], en_rows[0])]
|
pairs = [(rows[0], en_rows[0])]
|
||||||
else:
|
else:
|
||||||
# تطبیق ترتیبی فقط برای جدولهای دستنخورده — idها از ۱ پیوسته
|
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
|
||||||
ids = [row.id for row in rows]
|
en_lookups: list[dict[object, dict]] = []
|
||||||
if ids != list(range(1, len(rows) + 1)) or len(rows) != len(en_rows):
|
for key in match_keys:
|
||||||
skipped.append(getattr(model, "__tablename__", str(model)))
|
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
|
||||||
continue
|
pairs = []
|
||||||
pairs = list(zip(rows, en_rows))
|
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:
|
for row, en_row in pairs:
|
||||||
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل میشود
|
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل میشود
|
||||||
@@ -284,8 +294,10 @@ def backfill_en_content() -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
if total_fields:
|
if total_fields:
|
||||||
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
|
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
|
||||||
if skipped:
|
if unmatched:
|
||||||
print(f"جدولهای تغییرکرده (بدون backfill خودکار — از پنل مدیریت ترجمه کنید): {', '.join(skipped)}")
|
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
|
||||||
|
for name in unmatched:
|
||||||
|
print(f" - {name}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -136,8 +136,23 @@ def _shell(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if lang == "en":
|
if lang == "en":
|
||||||
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت و زبان سند عوض میشود
|
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض میشود
|
||||||
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
|
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
|
||||||
|
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
|
||||||
|
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
|
||||||
|
# description استاتیک حذف میشود تا با متای تزریقی تکراری نشود
|
||||||
|
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
|
||||||
|
# og:locale — در پوسته استاتیک بهروز میشود و از بلوک تزریقی حذف تا دوباره نوشته نشود
|
||||||
|
og_locale = "en_US" if lang == "en" else "fa_IR"
|
||||||
|
if re.search(r'<meta property="og:locale"', raw):
|
||||||
|
raw = re.sub(
|
||||||
|
r'(<meta property="og:locale" content=")[^"]*(")',
|
||||||
|
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
|
||||||
|
raw,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
tags = [t for t in tags if 'og:locale' not in t]
|
||||||
|
block = "\n ".join(tags)
|
||||||
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||||
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||||
if noscript:
|
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),
|
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
||||||
description=meta.description if meta else "",
|
description=meta.description if meta else "",
|
||||||
path="/blog",
|
path="/blog",
|
||||||
og_image=meta.image if meta else "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
|
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||||
|
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
ports:
|
ports:
|
||||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||||
|
|||||||
+10
-16
@@ -37,20 +37,13 @@ const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
|
|||||||
{ path: '/contact', element: <ContactPage /> },
|
{ path: '/contact', element: <ContactPage /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
function LocaleRoutes({ prefix }: { prefix: '' | '/en' }) {
|
// فرزند مستقیم <Routes> باید <Route> باشد — آرایههای Route بهجای کامپوننت واسط
|
||||||
return (
|
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||||
<>
|
<Route key={path} path={path} element={element} />
|
||||||
{PUBLIC_ROUTES.map(({ path, element }) => (
|
));
|
||||||
<Route
|
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||||
key={`${prefix}${path}`}
|
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
|
||||||
path={path === '/' ? (prefix || '/') : `${prefix}${path}`}
|
));
|
||||||
element={element}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{prefix === '' && <Route path="*" element={<Home />} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -75,8 +68,9 @@ function AnimatedRoutes() {
|
|||||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||||
<LocaleRoutes prefix="" />
|
{FA_ROUTES}
|
||||||
<LocaleRoutes prefix="/en" />
|
<Route path="*" element={<Home />} />
|
||||||
|
{EN_ROUTES}
|
||||||
</Routes>
|
</Routes>
|
||||||
</motion.main>
|
</motion.main>
|
||||||
</AnimatePresence>);
|
</AnimatePresence>);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ArticleDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
description: article.excerpt,
|
description: article.excerpt,
|
||||||
path: href(`/blog/${article.slug}`),
|
path: href(`/blog/${article.slug}`),
|
||||||
image: article.image,
|
image: article.image,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ProjectDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${project.title} | ${brand}`,
|
title: `${project.title} | ${brand.name}`,
|
||||||
description: project.summary,
|
description: project.summary,
|
||||||
path: href(`/projects/${project.slug}`),
|
path: href(`/projects/${project.slug}`),
|
||||||
image: project.image,
|
image: project.image,
|
||||||
|
|||||||
Reference in New Issue
Block a user