From e697807811ee3e861d6aff9f3677951db409f8af Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 31 Jul 2026 18:36:35 +0300 Subject: [PATCH] :sparkles: feat(knowledge): article YAML import in support portals + template download link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a standalone backend endpoint POST /api/v1/support/portals//articles/import/ that upserts articles by (portal, locale, slug): creates a new article or appends a revision, resolving category by categoryPath. No DB migrations, reuses existing create_article/add_revision services. Frontend: new portal article import modal + parseArticleYaml validator + article template (knowledge-template.yml / portal-articles-template.yml). Add a ".link has-icon" "Скачать шаблон" link to both the AI knowledge import modal and the new portal import modal, using the existing shared links.css standard and the existing download icon. --- .../support_portals/article_import.py | 173 ++++++++++++++++++ .../support_portals/article_import_views.py | 32 ++++ .../tests/test_article_import.py | 136 ++++++++++++++ .../hub_platform/support_portals/urls.py | 3 +- .../ai/knowledge/KnowledgeImportModal.tsx | 9 +- .../ai/knowledge/downloadKnowledgeTemplate.ts | 24 +++ .../PortalArticleImportModal.tsx | 92 ++++++++++ .../support-portals/PortalContent.tsx | 16 +- .../src/features/support-portals/api.ts | 18 ++ .../downloadArticleTemplate.ts | 31 ++++ .../src/features/support-portals/model.ts | 1 + .../support-portals/parseArticleYaml.ts | 72 ++++++++ 12 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 apps/backend/hub_platform/support_portals/article_import.py create mode 100644 apps/backend/hub_platform/support_portals/article_import_views.py create mode 100644 apps/backend/hub_platform/support_portals/tests/test_article_import.py create mode 100644 apps/internal-ui/src/features/ai/knowledge/downloadKnowledgeTemplate.ts create mode 100644 apps/internal-ui/src/features/support-portals/PortalArticleImportModal.tsx create mode 100644 apps/internal-ui/src/features/support-portals/downloadArticleTemplate.ts create mode 100644 apps/internal-ui/src/features/support-portals/parseArticleYaml.ts diff --git a/apps/backend/hub_platform/support_portals/article_import.py b/apps/backend/hub_platform/support_portals/article_import.py new file mode 100644 index 0000000..39f0150 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/article_import.py @@ -0,0 +1,173 @@ +from dataclasses import dataclass, field + +from django.core.exceptions import ValidationError +from django.db import transaction + +from hub_platform.support_portals.content_services import ( + add_revision, + create_article, + ensure_portal_editable, + update_article, +) +from hub_platform.support_portals.models import PortalCategory +from hub_platform.tenancy.context import TenantContext + + +@dataclass(slots=True) +class ArticleImportResult: + created: int = 0 + updated: int = 0 + unchanged: int = 0 + failed: list[dict[str, str]] = field(default_factory=list) + + def payload(self) -> dict[str, object]: + return { + "created": self.created, + "updated": self.updated, + "unchanged": self.unchanged, + "failed": self.failed, + } + + +def _required(document: dict[str, object], key: str) -> str: + value = document.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValidationError({key: f"{key} is required"}) + return value.strip() + + +def _content(document: dict[str, object]) -> str: + value = document.get("content") + if not isinstance(value, str): + raise ValidationError({"content": "Content string is required"}) + return value + + +def _locale(document: dict[str, object], *, default: str) -> str: + if "locale" not in document or document["locale"] in (None, ""): + return default + value = document["locale"] + if not isinstance(value, str): + raise ValidationError({"locale": "Locale must be a string"}) + return value.strip().lower() + + +def _summary(document: dict[str, object], *, default: str) -> str: + if "summary" not in document or document["summary"] is None: + return default + value = document["summary"] + if not isinstance(value, str): + raise ValidationError({"summary": "Summary must be a string"}) + return value.strip() + + +def _category_for_path(*, portal, raw_path: object) -> PortalCategory: + if not isinstance(raw_path, list) or not raw_path: + raise ValidationError({"categoryPath": "Non-empty category path required"}) + parent_id = None + category = None + for raw_name in raw_path: + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValidationError({"categoryPath": "Category names must be non-empty strings"}) + name = raw_name.strip() + try: + category = portal.categories.get(parent_id=parent_id, name=name) + except ( + PortalCategory.DoesNotExist, + PortalCategory.MultipleObjectsReturned, + ) as error: + raise ValidationError({"categoryPath": "Category path not found"}) from error + parent_id = category.id + assert category is not None + return category + + +def _validation_detail(error: ValidationError) -> str: + if hasattr(error, "message_dict"): + return "; ".join( + message for messages in error.message_dict.values() for message in messages + ) + return "; ".join(error.messages) + + +@transaction.atomic +def _import_document( + *, context: TenantContext, portal, document: dict[str, object] +) -> str: + ensure_portal_editable(portal) + slug = _required(document, "slug").lower() + title = _required(document, "title") + content = _content(document) + summary = _summary(document, default="") + locale = _locale(document, default=portal.default_locale) + category = _category_for_path(portal=portal, raw_path=document.get("categoryPath")) + + existing = portal.articles.filter(locale=locale, slug=slug).first() + if existing is None: + create_article( + context=context, + portal=portal, + data={ + "categoryId": category.id, + "slug": slug, + "locale": locale, + "title": title, + "summary": summary, + "content": content, + }, + ) + return "created" + + latest = existing.revisions.order_by("-revision").first() + same_category = existing.category_id == category.id + same_content = ( + latest is not None + and latest.title == title + and latest.summary == summary + and latest.content == content + ) + if same_content and same_category: + return "unchanged" + if not same_category: + update_article( + article=existing, + data={ + "categoryId": category.id, + "slug": existing.slug, + "locale": existing.locale, + }, + ) + if not same_content: + add_revision( + context=context, + article=existing, + data={"title": title, "summary": summary, "content": content}, + ) + return "updated" + + +def import_articles( + *, context: TenantContext, portal, documents: list[object] +) -> ArticleImportResult: + result = ArticleImportResult() + for raw_document in documents: + slug = "" + if isinstance(raw_document, dict): + raw_slug = raw_document.get("slug") + slug = raw_slug.strip() if isinstance(raw_slug, str) else "" + try: + if not isinstance(raw_document, dict): + raise ValidationError({"article": "Article must be an object"}) + outcome = _import_document( + context=context, portal=portal, document=raw_document + ) + except ValidationError as error: + result.failed.append({"slug": slug, "detail": _validation_detail(error)}) + continue + if outcome == "created": + result.created += 1 + elif outcome == "updated": + result.updated += 1 + else: + result.unchanged += 1 + return result diff --git a/apps/backend/hub_platform/support_portals/article_import_views.py b/apps/backend/hub_platform/support_portals/article_import_views.py new file mode 100644 index 0000000..7f6733e --- /dev/null +++ b/apps/backend/hub_platform/support_portals/article_import_views.py @@ -0,0 +1,32 @@ +from rest_framework.request import Request +from rest_framework.response import Response + +from hub_platform.identity.audit import record_audit_event +from hub_platform.support_portals.article_import import import_articles +from hub_platform.support_portals.portal_views import PortalBaseView + + +class ArticleImportView(PortalBaseView): + def post(self, request: Request, portal_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + articles = request.data.get("articles") + if not isinstance(articles, list) or not articles: + return Response({"detail": "articles must be a non-empty list"}, status=400) + result = import_articles( + context=request.tenant_context, + portal=portal, + documents=articles, + ) + payload = result.payload() + record_audit_event( + action="support_portal.articles_imported", + actor=request.user, + organization=request.tenant_context.organization, + object_type="PortalArticle", + object_id="", + payload=payload, + request=request, + ) + return Response(payload, status=201) diff --git a/apps/backend/hub_platform/support_portals/tests/test_article_import.py b/apps/backend/hub_platform/support_portals/tests/test_article_import.py new file mode 100644 index 0000000..11b7bb1 --- /dev/null +++ b/apps/backend/hub_platform/support_portals/tests/test_article_import.py @@ -0,0 +1,136 @@ +from hub_platform.support_portals.models import PortalArticle +from hub_platform.support_portals.tests.base import SupportPortalTestCase + + +class SupportPortalArticleImportTests(SupportPortalTestCase): + def _category( + self, + portal_id: int, + name: str, + slug: str, + parent_id: int | None = None, + ) -> int: + payload = {"slug": slug, "name": name} + if parent_id is not None: + payload["parentId"] = parent_id + response = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + payload, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + return response.json()["category"]["id"] + + def _import(self, portal_id: int, articles: list[object]): + return self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/import/", + data={"articles": articles}, + format="json", + ) + + def test_import_creates_new_article_from_category_path(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + parent = self._category(portal_id, "Возврат", "returns") + self._category(portal_id, "Оформление", "checkout", parent_id=parent) + + response = self._import( + portal_id, + [ + { + "slug": "vozvrat-tovara", + "categoryPath": ["Возврат", "Оформление"], + "title": "Как оформить возврат", + "summary": "Короткое описание", + "content": "Шаги оформления возврата.", + } + ], + ) + + self.assertEqual(response.status_code, 201, response.content) + self.assertEqual(response.json()["created"], 1) + article = PortalArticle.objects.get(slug="vozvrat-tovara") + self.assertEqual(article.category.name, "Оформление") + self.assertEqual(article.revisions.count(), 1) + self.assertEqual(article.revisions.first().title, "Как оформить возврат") + + def test_import_updates_existing_article_by_slug_with_new_revision(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + self._category(portal_id, "Возврат", "returns") + + self._import( + portal_id, + [ + { + "slug": "garantiya", + "categoryPath": ["Возврат"], + "title": "Гарантия", + "content": "Версия 1", + } + ], + ) + + response = self._import( + portal_id, + [ + { + "slug": "garantiya", + "categoryPath": ["Возврат"], + "title": "Гарантия", + "content": "Версия 2", + } + ], + ) + + self.assertEqual(response.status_code, 201, response.content) + body = response.json() + self.assertEqual(body["updated"], 1) + self.assertEqual(body["created"], 0) + article = PortalArticle.objects.get(slug="garantiya") + self.assertEqual(article.revisions.count(), 2) + self.assertEqual(article.revisions.order_by("-revision").first().content, "Версия 2") + + def test_import_marks_unchanged_article_when_content_matches(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + self._category(portal_id, "Возврат", "returns") + + document = { + "slug": "neizmennyj", + "categoryPath": ["Возврат"], + "title": "Без изменений", + "content": "Текст", + } + self._import(portal_id, [document]) + response = self._import(portal_id, [document]) + + self.assertEqual(response.status_code, 201, response.content) + self.assertEqual(response.json()["unchanged"], 1) + article = PortalArticle.objects.get(slug="neizmennyj") + self.assertEqual(article.revisions.count(), 1) + + def test_import_reports_failed_document_for_unknown_category_path(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + self._category(portal_id, "Возврат", "returns") + + response = self._import( + portal_id, + [ + { + "slug": "problemnyj", + "categoryPath": ["Не существует"], + "title": "Проблема", + "content": "Текст", + } + ], + ) + + self.assertEqual(response.status_code, 201, response.content) + body = response.json() + self.assertEqual(body["created"], 0) + self.assertEqual(len(body["failed"]), 1) + self.assertEqual(body["failed"][0]["slug"], "problemnyj") + self.assertFalse(PortalArticle.objects.filter(slug="problemnyj").exists()) + + def test_import_rejects_empty_articles_list(self) -> None: + portal_id = self.create_portal().json()["portal"]["id"] + response = self._import(portal_id, []) + self.assertEqual(response.status_code, 400) diff --git a/apps/backend/hub_platform/support_portals/urls.py b/apps/backend/hub_platform/support_portals/urls.py index 6b06f48..5de3c52 100644 --- a/apps/backend/hub_platform/support_portals/urls.py +++ b/apps/backend/hub_platform/support_portals/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from hub_platform.support_portals import content_views, portal_views +from hub_platform.support_portals import article_import_views, content_views, portal_views urlpatterns = [ path("", portal_views.PortalListView.as_view(), name="support-portal-list"), @@ -13,6 +13,7 @@ urlpatterns = [ path("/categories/", content_views.CategoryListView.as_view(), name="support-portal-categories"), path("/categories//", content_views.CategoryDetailView.as_view(), name="support-portal-category-detail"), path("/articles/", content_views.ArticleListView.as_view(), name="support-portal-articles"), + path("/articles/import/", article_import_views.ArticleImportView.as_view(), name="support-portal-article-import"), path("/articles//", content_views.ArticleDetailView.as_view(), name="support-portal-article"), path("/articles//revisions/", content_views.ArticleRevisionListView.as_view(), name="support-portal-article-revisions"), path("/articles//publish/", content_views.ArticlePublishView.as_view(), name="support-portal-article-publish"), diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx index 3b26325..d4606e9 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx @@ -1,7 +1,9 @@ import { Modal } from "antd"; import { useState } from "react"; +import { Icon } from "../../../shared/icons"; import { Button } from "../../../shared/ui-controls"; +import { downloadKnowledgeTemplate } from "./downloadKnowledgeTemplate"; import { importKnowledge, type KnowledgeImportReport } from "./model"; import { parseKnowledgeYaml, type ParsedKnowledgeYaml } from "./parseKnowledgeYaml"; @@ -48,7 +50,12 @@ export function KnowledgeImportModal({ onClose, onImported }: KnowledgeImportMod
{parsed && ( diff --git a/apps/internal-ui/src/features/ai/knowledge/downloadKnowledgeTemplate.ts b/apps/internal-ui/src/features/ai/knowledge/downloadKnowledgeTemplate.ts new file mode 100644 index 0000000..a37a70a --- /dev/null +++ b/apps/internal-ui/src/features/ai/knowledge/downloadKnowledgeTemplate.ts @@ -0,0 +1,24 @@ +// Каркас YAML-файла для импорта знаний (формат parseKnowledgeYaml): +// documents: [{ title, description?, content }]. description необязателен — +// шаблон показывает оба варианта. +const KNOWLEDGE_TEMPLATE = `documents: + - title: "Название знания" + description: "Короткое описание (необязательно)" + content: | + Основной текст знания. + + - title: "Название знания без описания" + content: | + Основной текст знания. +`; + +export function downloadKnowledgeTemplate(): void { + const url = URL.createObjectURL( + new Blob([`\uFEFF${KNOWLEDGE_TEMPLATE}`], { type: "text/yaml;charset=utf-8" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "knowledge-template.yml"; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/internal-ui/src/features/support-portals/PortalArticleImportModal.tsx b/apps/internal-ui/src/features/support-portals/PortalArticleImportModal.tsx new file mode 100644 index 0000000..d887805 --- /dev/null +++ b/apps/internal-ui/src/features/support-portals/PortalArticleImportModal.tsx @@ -0,0 +1,92 @@ +import { Modal } from "antd"; +import { useState } from "react"; + +import { Icon } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import type { ArticleImportReport } from "./api"; +import { downloadArticleTemplate } from "./downloadArticleTemplate"; +import { importPortalArticles, portalErrorMessage } from "./model"; +import { parseArticleYaml, type ParsedArticleYaml } from "./parseArticleYaml"; + +type PortalArticleImportModalProps = { + portalId: number; + onClose: () => void; + onImported: () => void; +}; + +export function PortalArticleImportModal({ portalId, onClose, onImported }: PortalArticleImportModalProps) { + const [parsed, setParsed] = useState(null); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function onFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + setError(null); + setResult(null); + try { + setParsed(parseArticleYaml(await file.text())); + } catch (caught) { + setParsed(null); + setError(caught instanceof Error ? caught.message : "Не удалось прочитать файл"); + } + } + + async function submit() { + if (!parsed) return; + setBusy(true); + setError(null); + try { + setResult(await importPortalArticles(portalId, parsed.articles)); + setParsed(null); + onImported(); + } catch (caught) { + setError(portalErrorMessage(caught, "Не удалось импортировать статьи")); + } finally { + setBusy(false); + } + } + + return ( + +
+ + {parsed && ( +
+ К импорту: {parsed.articles.length} статей. Совпадение по адресу создаст новую версию. +
+ )} + {result && ( +
+
+ Создано: {result.created} · Обновлено: {result.updated} · Без изменений: {result.unchanged} +
+ {result.failed.length > 0 && ( +
    + {result.failed.map((item, index) => ( +
  • {item.slug ? `«${item.slug}»: ` : ""}{item.detail}
  • + ))} +
+ )} +
+ )} + {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/support-portals/PortalContent.tsx b/apps/internal-ui/src/features/support-portals/PortalContent.tsx index f750cb0..2fd12df 100644 --- a/apps/internal-ui/src/features/support-portals/PortalContent.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalContent.tsx @@ -6,6 +6,7 @@ import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibra import { DecisionDialog } from "../../shared/DecisionDialog"; import { Button } from "../../shared/ui-controls"; import { PortalArticleEditor } from "./PortalArticleEditor"; +import { PortalArticleImportModal } from "./PortalArticleImportModal"; import { PortalArticleTable } from "./PortalArticleTable"; import { PortalCategoryManagement } from "./PortalCategoryManagement"; import { @@ -43,6 +44,7 @@ export function PortalContent({ const [status, setStatus] = useState(""); const [editing, setEditing] = useState(undefined); const [managingCategories, setManagingCategories] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [decision, setDecision] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -128,7 +130,12 @@ export function PortalContent({ 0 && } + action={canManage && categories.length > 0 && ( + <> + + + + )} > @@ -157,6 +164,13 @@ export function PortalContent({
{managingCategories && setManagingCategories(false)} />} + {importOpen && ( + setImportOpen(false)} + onImported={() => void reload()} + /> + )} setDecision(null)} diff --git a/apps/internal-ui/src/features/support-portals/api.ts b/apps/internal-ui/src/features/support-portals/api.ts index 675e1a0..0221d17 100644 --- a/apps/internal-ui/src/features/support-portals/api.ts +++ b/apps/internal-ui/src/features/support-portals/api.ts @@ -1,5 +1,6 @@ import { api } from "../../api/client"; import type { Channel } from "../channels/types"; +import type { ArticleImportDocument } from "./parseArticleYaml"; import type { ArticleRevision, PortalArticle, @@ -182,3 +183,20 @@ export function archivePortalArticle( method: "POST", }); } + +export type ArticleImportReport = { + created: number; + updated: number; + unchanged: number; + failed: Array<{ slug?: string; detail: string }>; +}; + +export function importPortalArticles( + portalId: number, + articles: ArticleImportDocument[], +): Promise { + return api(`/api/v1/support/portals/${portalId}/articles/import/`, { + method: "POST", + body: JSON.stringify({ articles }), + }); +} diff --git a/apps/internal-ui/src/features/support-portals/downloadArticleTemplate.ts b/apps/internal-ui/src/features/support-portals/downloadArticleTemplate.ts new file mode 100644 index 0000000..0e1eef1 --- /dev/null +++ b/apps/internal-ui/src/features/support-portals/downloadArticleTemplate.ts @@ -0,0 +1,31 @@ +// Каркас YAML-файла для импорта статей портала (формат parseArticleYaml): +// articles: [{ slug, categoryPath: [..], locale?, title, summary?, content }]. +// summary необязателен — шаблон показывает оба варианта. +const ARTICLE_TEMPLATE = `articles: + - slug: "kak-oformit-vozvrat" + categoryPath: + - "Возврат" + - "Оформление" + title: "Как оформить возврат" + summary: "Короткое описание (необязательно)" + content: | + Текст статьи в Markdown. + + - slug: "garantiya" + categoryPath: + - "Возврат" + title: "Гарантия" + content: | + Текст статьи без описания. +`; + +export function downloadArticleTemplate(): void { + const url = URL.createObjectURL( + new Blob([`\uFEFF${ARTICLE_TEMPLATE}`], { type: "text/yaml;charset=utf-8" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "portal-articles-template.yml"; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/internal-ui/src/features/support-portals/model.ts b/apps/internal-ui/src/features/support-portals/model.ts index 8cdaa0e..9c287a5 100644 --- a/apps/internal-ui/src/features/support-portals/model.ts +++ b/apps/internal-ui/src/features/support-portals/model.ts @@ -109,6 +109,7 @@ export { createPortalCategory, createSupportPortal, deletePortalCategory, + importPortalArticles, listPortalArticles, listPortalCategories, listPortalSupportChannels, diff --git a/apps/internal-ui/src/features/support-portals/parseArticleYaml.ts b/apps/internal-ui/src/features/support-portals/parseArticleYaml.ts new file mode 100644 index 0000000..60a9c35 --- /dev/null +++ b/apps/internal-ui/src/features/support-portals/parseArticleYaml.ts @@ -0,0 +1,72 @@ +import { load as parseYaml } from "js-yaml"; + +export type ArticleImportDocument = { + slug: string; + categoryPath: string[]; + locale?: string; + title: string; + summary?: string; + content: string; +}; + +export type ParsedArticleYaml = { + articles: ArticleImportDocument[]; +}; + +// Парсит YAML импорта статей портала: +// { articles: [{ slug, categoryPath: [..], locale?, title, summary?, content }] }. +// Ключ совпадания — (slug, locale). Бросает Error с человекочитаемым сообщением +// при невалидном YAML или структуре. +export function parseArticleYaml(text: string): ParsedArticleYaml { + let data: unknown; + try { + data = parseYaml(text); + } catch (error) { + throw new Error(error instanceof Error ? `Невалидный YAML: ${error.message}` : "Невалидный YAML"); + } + if (!data || typeof data !== "object") { + throw new Error("Ожидается объект верхнего уровня с ключом articles"); + } + const root = data as { articles?: unknown }; + if (!Array.isArray(root.articles) || root.articles.length === 0) { + throw new Error("articles должен быть непустым списком"); + } + const articles: ArticleImportDocument[] = []; + root.articles.forEach((item, index) => { + if (!item || typeof item !== "object") { + throw new Error(`Статья #${index + 1}: должна быть объектом`); + } + const record = item as Record; + const slug = record.slug === undefined || record.slug === null ? "" : String(record.slug).trim().toLowerCase(); + if (!slug) { + throw new Error(`Статья #${index + 1}: обязательное поле «slug» пустое`); + } + const title = record.title === undefined || record.title === null ? "" : String(record.title).trim(); + if (!title) { + throw new Error(`Статья #${index + 1}: обязательное поле «title» пустое`); + } + const content = record.content === undefined || record.content === null ? "" : String(record.content); + if (!content.trim()) { + throw new Error(`Статья #${index + 1}: обязательное поле «content» пустое`); + } + if (!Array.isArray(record.categoryPath) || record.categoryPath.length === 0) { + throw new Error(`Статья #${index + 1}: обязательное поле «categoryPath» — непустой список разделов`); + } + const categoryPath: string[] = []; + record.categoryPath.forEach((segment, segmentIndex) => { + if (segment === undefined || segment === null || !String(segment).trim()) { + throw new Error(`Статья #${index + 1}: раздел #${segmentIndex + 1} пустой`); + } + categoryPath.push(String(segment).trim()); + }); + const doc: ArticleImportDocument = { slug, categoryPath, title, content }; + if (record.locale !== undefined && record.locale !== null && String(record.locale).trim()) { + doc.locale = String(record.locale).trim().toLowerCase(); + } + if (record.summary !== undefined && record.summary !== null && String(record.summary).trim()) { + doc.summary = String(record.summary).trim(); + } + articles.push(doc); + }); + return { articles }; +}