Content: match imported chapters/sections/articles by title; slug without number

This commit is contained in:
2026-09-22 17:53:59 +03:00
parent 631a927787
commit e6e11dd32c
3 changed files with 33 additions and 12 deletions

View File

@@ -107,7 +107,13 @@ printf("Ответ: 42\n");
## Импорт: что происходит с материалами
* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи).
* Главы, разделы и статьи из архива **обновляются**. Совпадение ищется **по названию**
(глава — по названию главы, раздел — по названию раздела внутри главы, статья — по названию
внутри раздела); если по названию не нашли, для совместимости проверяется slug. Поэтому один
и тот же материал, созданный вручную и загруженный из архива, не дублируется.
* Номера в именах каталогов архива (`02-Матрицы`, `01-Двумерные массивы`) задают только
**порядок** (позицию). В адрес страницы они не попадают: slug формируется из названия, как и
при ручном создании (`Матрицы` → `matricy`, `Функции` → `funkcii`).
* Режим **синхронизации** (галка в форме импорта) дополнительно **удаляет** разделы и статьи
главы, которых нет в архиве — архив считается источником истины («удалено устаревших: N»).
* Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть

View File

@@ -503,11 +503,17 @@ class ContentService(
for (p in parsed) {
if (currentChapter?.title != p.chapterDir) {
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
currentChapter = chapters.findBySlug(slug)?.apply {
// Ищем по названию, затем по slug: так импорт находит главу, созданную
// вручную или импортированную ранее, и не плодит дубли.
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: chapters.save(
ContentChapter(title = p.chapterDir, slug = slug, position = p.chapterPos),
ContentChapter(
title = p.chapterDir,
slug = uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
),
)
currentChapter = chapters.save(currentChapter!!)
touchedChapters.add(currentChapter!!.id)
@@ -517,11 +523,17 @@ class ContentService(
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = sections.findByChapterIdAndSlug(chapter.id, slug)?.apply {
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: sections.save(
ContentSection(chapterId = chapter.id, title = p.sectionDir, slug = slug, position = p.sectionPos),
ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()),
position = p.sectionPos,
),
)
currentSection = sections.save(currentSection!!)
seenSections.add(currentSection!!.id)
@@ -536,7 +548,8 @@ class ContentService(
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
?: articleSlug(p)
?: "article-${artCount + 1}"
val existing = articles.findBySectionIdAndSlug(section.id, slug)
val existing = articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
@@ -575,14 +588,13 @@ class ContentService(
)
}
private fun chapterSlug(p: ParsedArticle): String? =
slugify("%02d".format(p.chapterPos) + "-" + p.chapterDir)
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
private fun sectionSlug(p: ParsedArticle): String? =
slugify("%02d".format(p.sectionPos) + "-" + p.sectionDir)
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
private fun articleSlug(p: ParsedArticle): String? =
slugify("%02d".format(p.articlePos) + "-" + p.fileTitle)
private fun articleSlug(p: ParsedArticle): String? = slugify(p.fileTitle)
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()

View File

@@ -9,16 +9,19 @@ import java.util.UUID
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
fun findBySlug(slug: String): ContentChapter?
fun findByTitleIgnoreCase(title: String): ContentChapter?
}
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection?
}
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle?
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
}