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 7f4a518e46
commit d81cebef70
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»). главы, которых нет в архиве — архив считается источником истины («удалено устаревших: N»).
* Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть * Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть

View File

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

View File

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