Content import: sync sections and articles (archive is the source of truth)
- importZip now updates titles/positions of existing chapters and sections and removes sections/articles of archived chapters that are missing in the archive (so restructured material no longer leaves stale sections behind); removed count is reported in ContentImportResult and in the admin flash message - docs: note about import semantics in the class diagram and sequences
This commit is contained in:
@@ -229,6 +229,8 @@ classDiagram
|
||||
class ContentSectionRepository
|
||||
class ContentArticleRepository
|
||||
|
||||
note for ContentService "Импорт ZIP — источник истины для глав из архива: устаревшие разделы и статьи удаляются (removed)"
|
||||
|
||||
LabController --> LabService
|
||||
ContentPublicController --> ContentService
|
||||
ContentAdminController --> ContentService
|
||||
|
||||
@@ -292,6 +292,11 @@ sequenceDiagram
|
||||
Note over A,AC: админ открывает скрытую статью через /api/v1/content/all/{c}/{s}/{a}
|
||||
```
|
||||
|
||||
> **Импорт ZIP — источник истины для глав, которые есть в архиве.** Разделы и статьи,
|
||||
> которых нет в архиве, удаляются из этих глав (счётчик `removed` в результате импорта);
|
||||
> главы, отсутствующие в архиве целиком, не затрагиваются. Поэтому после крупной
|
||||
> перестройки структуры старую главу, которой больше нет в архиве, нужно удалить в интерфейсе.
|
||||
|
||||
## 10. Страница группы у администратора (агрегация данных)
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -59,6 +59,8 @@ data class ContentImportResult(
|
||||
val sections: Int,
|
||||
val articles: Int,
|
||||
val assets: Int,
|
||||
/** Сколько устаревших разделов и статей удалено при синхронизации. */
|
||||
val removed: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -453,24 +455,38 @@ class ContentService(
|
||||
var chCount = 0
|
||||
var secCount = 0
|
||||
var artCount = 0
|
||||
var removedCount = 0
|
||||
var currentChapter: ContentChapter? = null
|
||||
var currentSection: ContentSection? = null
|
||||
val touchedChapters = mutableSetOf<UUID>()
|
||||
val seenSections = mutableSetOf<UUID>()
|
||||
val seenArticles = mutableSetOf<UUID>()
|
||||
|
||||
for (p in parsed) {
|
||||
if (currentChapter?.title != p.chapterDir) {
|
||||
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
|
||||
currentChapter = chapters.findBySlug(slug) ?: chapters.save(
|
||||
currentChapter = chapters.findBySlug(slug)?.apply {
|
||||
title = p.chapterDir
|
||||
position = p.chapterPos
|
||||
} ?: chapters.save(
|
||||
ContentChapter(title = p.chapterDir, slug = slug, position = p.chapterPos),
|
||||
)
|
||||
currentChapter = chapters.save(currentChapter!!)
|
||||
touchedChapters.add(currentChapter!!.id)
|
||||
chCount += 1
|
||||
currentSection = null
|
||||
}
|
||||
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
||||
if (currentSection?.title != p.sectionDir) {
|
||||
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
||||
currentSection = sections.findByChapterIdAndSlug(chapter.id, slug) ?: sections.save(
|
||||
currentSection = 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),
|
||||
)
|
||||
currentSection = sections.save(currentSection!!)
|
||||
seenSections.add(currentSection!!.id)
|
||||
secCount += 1
|
||||
}
|
||||
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
||||
@@ -489,10 +505,34 @@ class ContentService(
|
||||
position = p.articlePos
|
||||
}
|
||||
articles.save(article)
|
||||
seenArticles.add(article.id)
|
||||
artCount += 1
|
||||
}
|
||||
|
||||
return ContentImportResult(chapters = chCount, sections = secCount, articles = artCount, assets = linkRewrite.size)
|
||||
// Синхронизация: в главах из архива удаляем разделы и статьи, которых в архиве нет.
|
||||
for (chapterId in touchedChapters) {
|
||||
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
|
||||
if (section.id in seenSections) {
|
||||
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
|
||||
if (article.id !in seenArticles) {
|
||||
articles.delete(article)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
removedCount += articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
|
||||
sections.delete(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentImportResult(
|
||||
chapters = chCount,
|
||||
sections = secCount,
|
||||
articles = artCount,
|
||||
assets = linkRewrite.size,
|
||||
removed = removedCount,
|
||||
)
|
||||
}
|
||||
|
||||
private fun chapterSlug(p: ParsedArticle): String? =
|
||||
|
||||
@@ -38,9 +38,11 @@ class ContentAdminController(
|
||||
}
|
||||
return try {
|
||||
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes)
|
||||
val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else ""
|
||||
redirect.addFlashAttribute(
|
||||
"notice",
|
||||
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, статей ${result.articles}, картинок ${result.assets}",
|
||||
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
|
||||
"статей ${result.articles}, картинок ${result.assets}$removedNote",
|
||||
)
|
||||
"redirect:/admin/content"
|
||||
} catch (ex: ApiException) {
|
||||
|
||||
@@ -365,6 +365,7 @@ data class ContentImportResultDto(
|
||||
val sections: Int = 0,
|
||||
val articles: Int = 0,
|
||||
val assets: Int = 0,
|
||||
val removed: Int = 0,
|
||||
)
|
||||
|
||||
class DownloadDto(
|
||||
|
||||
Reference in New Issue
Block a user