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 ContentSectionRepository
|
||||||
class ContentArticleRepository
|
class ContentArticleRepository
|
||||||
|
|
||||||
|
note for ContentService "Импорт ZIP — источник истины для глав из архива: устаревшие разделы и статьи удаляются (removed)"
|
||||||
|
|
||||||
LabController --> LabService
|
LabController --> LabService
|
||||||
ContentPublicController --> ContentService
|
ContentPublicController --> ContentService
|
||||||
ContentAdminController --> ContentService
|
ContentAdminController --> ContentService
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ sequenceDiagram
|
|||||||
Note over A,AC: админ открывает скрытую статью через /api/v1/content/all/{c}/{s}/{a}
|
Note over A,AC: админ открывает скрытую статью через /api/v1/content/all/{c}/{s}/{a}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Импорт ZIP — источник истины для глав, которые есть в архиве.** Разделы и статьи,
|
||||||
|
> которых нет в архиве, удаляются из этих глав (счётчик `removed` в результате импорта);
|
||||||
|
> главы, отсутствующие в архиве целиком, не затрагиваются. Поэтому после крупной
|
||||||
|
> перестройки структуры старую главу, которой больше нет в архиве, нужно удалить в интерфейсе.
|
||||||
|
|
||||||
## 10. Страница группы у администратора (агрегация данных)
|
## 10. Страница группы у администратора (агрегация данных)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ data class ContentImportResult(
|
|||||||
val sections: Int,
|
val sections: Int,
|
||||||
val articles: Int,
|
val articles: Int,
|
||||||
val assets: Int,
|
val assets: Int,
|
||||||
|
/** Сколько устаревших разделов и статей удалено при синхронизации. */
|
||||||
|
val removed: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -453,24 +455,38 @@ class ContentService(
|
|||||||
var chCount = 0
|
var chCount = 0
|
||||||
var secCount = 0
|
var secCount = 0
|
||||||
var artCount = 0
|
var artCount = 0
|
||||||
|
var removedCount = 0
|
||||||
var currentChapter: ContentChapter? = null
|
var currentChapter: ContentChapter? = null
|
||||||
var currentSection: ContentSection? = null
|
var currentSection: ContentSection? = null
|
||||||
|
val touchedChapters = mutableSetOf<UUID>()
|
||||||
|
val seenSections = mutableSetOf<UUID>()
|
||||||
|
val seenArticles = mutableSetOf<UUID>()
|
||||||
|
|
||||||
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) ?: chapters.save(
|
currentChapter = 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 = slug, position = p.chapterPos),
|
||||||
)
|
)
|
||||||
|
currentChapter = chapters.save(currentChapter!!)
|
||||||
|
touchedChapters.add(currentChapter!!.id)
|
||||||
chCount += 1
|
chCount += 1
|
||||||
currentSection = null
|
currentSection = null
|
||||||
}
|
}
|
||||||
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) ?: 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),
|
ContentSection(chapterId = chapter.id, title = p.sectionDir, slug = slug, position = p.sectionPos),
|
||||||
)
|
)
|
||||||
|
currentSection = sections.save(currentSection!!)
|
||||||
|
seenSections.add(currentSection!!.id)
|
||||||
secCount += 1
|
secCount += 1
|
||||||
}
|
}
|
||||||
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
||||||
@@ -489,10 +505,34 @@ class ContentService(
|
|||||||
position = p.articlePos
|
position = p.articlePos
|
||||||
}
|
}
|
||||||
articles.save(article)
|
articles.save(article)
|
||||||
|
seenArticles.add(article.id)
|
||||||
artCount += 1
|
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? =
|
private fun chapterSlug(p: ParsedArticle): String? =
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ class ContentAdminController(
|
|||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes)
|
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes)
|
||||||
|
val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else ""
|
||||||
redirect.addFlashAttribute(
|
redirect.addFlashAttribute(
|
||||||
"notice",
|
"notice",
|
||||||
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, статей ${result.articles}, картинок ${result.assets}",
|
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
|
||||||
|
"статей ${result.articles}, картинок ${result.assets}$removedNote",
|
||||||
)
|
)
|
||||||
"redirect:/admin/content"
|
"redirect:/admin/content"
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ data class ContentImportResultDto(
|
|||||||
val sections: Int = 0,
|
val sections: Int = 0,
|
||||||
val articles: Int = 0,
|
val articles: Int = 0,
|
||||||
val assets: Int = 0,
|
val assets: Int = 0,
|
||||||
|
val removed: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
class DownloadDto(
|
class DownloadDto(
|
||||||
|
|||||||
Reference in New Issue
Block a user