From e6e11dd32c79ef91d17f0b4355b38ba7b65ac534 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 22 Sep 2026 17:53:59 +0300 Subject: [PATCH] Content: match imported chapters/sections/articles by title; slug without number --- docs/06-authoring.md | 8 ++++- .../lms/academic/content/ContentService.kt | 34 +++++++++++++------ .../lms/academic/repo/ContentRepositories.kt | 3 ++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/06-authoring.md b/docs/06-authoring.md index ea65fa2..a1a549a 100644 --- a/docs/06-authoring.md +++ b/docs/06-authoring.md @@ -107,7 +107,13 @@ printf("Ответ: 42\n"); ## Импорт: что происходит с материалами -* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи). +* Главы, разделы и статьи из архива **обновляются**. Совпадение ищется **по названию** + (глава — по названию главы, раздел — по названию раздела внутри главы, статья — по названию + внутри раздела); если по названию не нашли, для совместимости проверяется slug. Поэтому один + и тот же материал, созданный вручную и загруженный из архива, не дублируется. +* Номера в именах каталогов архива (`02-Матрицы`, `01-Двумерные массивы`) задают только + **порядок** (позицию). В адрес страницы они не попадают: slug формируется из названия, как и + при ручном создании (`Матрицы` → `matricy`, `Функции` → `funkcii`). * Режим **синхронизации** (галка в форме импорта) дополнительно **удаляет** разделы и статьи главы, которых нет в архиве — архив считается источником истины («удалено устаревших: N»). * Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index f2cb217..0cb3cea 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -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 { val result = mutableMapOf() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt index 7357d95..0437b20 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt @@ -9,16 +9,19 @@ import java.util.UUID interface ContentChapterRepository : JpaRepository { fun findAllByOrderByPositionAscIdAsc(): List fun findBySlug(slug: String): ContentChapter? + fun findByTitleIgnoreCase(title: String): ContentChapter? } interface ContentSectionRepository : JpaRepository { fun findAllByOrderByPositionAscIdAsc(): List fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection? + fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection? } interface ContentArticleRepository : JpaRepository { fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle? + fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle? fun findAllByOrderByPositionAscIdAsc(): List } \ No newline at end of file