Lint: strict mode on; tune detekt thresholds; more decompositions
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Импорт материалов из zip со структурой каталогов:
|
||||
* `chapters/01-глава/01-раздел/01-статья.md` (front-matter: `title`, `slug`),
|
||||
* картинки — в `assets/`.
|
||||
*
|
||||
* Главы, разделы и статьи сопоставляются по названию (запасной вариант — по slug),
|
||||
* поэтому повторный импорт не плодит дубли и не затирает материалы, созданные вручную.
|
||||
*/
|
||||
@Service
|
||||
class ContentImporter(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
private val assets: AssetStorage,
|
||||
) {
|
||||
@Transactional
|
||||
fun importZip(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
sync: Boolean = true,
|
||||
): ContentImportResult {
|
||||
requireZip(filename)
|
||||
val files = readZip(bytes)
|
||||
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
||||
|
||||
val contentFiles = files.filterKeys(::isContentPath)
|
||||
if (contentFiles.isEmpty()) {
|
||||
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||
}
|
||||
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
|
||||
val parsed = parseArticles(contentFiles)
|
||||
return mergeIntoDb(parsed, linkRewrite, sync)
|
||||
}
|
||||
|
||||
private fun requireZip(filename: String) {
|
||||
if (!filename.lowercase().endsWith(".zip")) throw BadRequestException("Ожидается .zip архив")
|
||||
}
|
||||
|
||||
private fun mergeIntoDb(
|
||||
parsed: List<ParsedArticle>,
|
||||
linkRewrite: Map<String, String>,
|
||||
sync: Boolean,
|
||||
): ContentImportResult {
|
||||
val state = ImportState()
|
||||
for (p in parsed) {
|
||||
val chapter = chapterFor(p, state)
|
||||
val section = sectionFor(chapter, p, state)
|
||||
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
|
||||
upsertArticle(section, p, body, state)
|
||||
}
|
||||
val removed = if (sync) removeMissing(state) else 0
|
||||
return ContentImportResult(
|
||||
chapters = state.chapters,
|
||||
sections = state.sections,
|
||||
articles = state.articles,
|
||||
assets = linkRewrite.size,
|
||||
removed = removed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun chapterFor(
|
||||
p: ParsedArticle,
|
||||
state: ImportState,
|
||||
): ContentChapter {
|
||||
if (state.chapter?.title != p.chapterDir) {
|
||||
val slug = Slugs.slugify(p.chapterDir) ?: "chapter-${state.chapters + 1}"
|
||||
val existing = chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug)
|
||||
val entity =
|
||||
existing?.apply {
|
||||
title = p.chapterDir
|
||||
position = p.chapterPos
|
||||
} ?: ContentChapter(
|
||||
title = p.chapterDir,
|
||||
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
||||
position = p.chapterPos,
|
||||
)
|
||||
state.chapter = chapters.save(entity)
|
||||
state.touchedChapters.add(state.chapter!!.id)
|
||||
state.chapters += 1
|
||||
state.section = null
|
||||
}
|
||||
return state.chapter ?: throw BadRequestException("Не удалось определить главу")
|
||||
}
|
||||
|
||||
private fun sectionFor(
|
||||
chapter: ContentChapter,
|
||||
p: ParsedArticle,
|
||||
state: ImportState,
|
||||
): ContentSection {
|
||||
if (state.section?.title != p.sectionDir) {
|
||||
val slug = Slugs.slugify(p.sectionDir) ?: "section-${state.sections + 1}"
|
||||
val existing =
|
||||
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
|
||||
?: sections.findByChapterIdAndSlug(chapter.id, slug)
|
||||
val entity =
|
||||
existing?.apply {
|
||||
title = p.sectionDir
|
||||
position = p.sectionPos
|
||||
} ?: ContentSection(
|
||||
chapterId = chapter.id,
|
||||
title = p.sectionDir,
|
||||
slug =
|
||||
Slugs.uniqueSlug(
|
||||
p.sectionDir,
|
||||
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
|
||||
),
|
||||
position = p.sectionPos,
|
||||
)
|
||||
state.section = sections.save(entity)
|
||||
state.seenSections.add(state.section!!.id)
|
||||
state.sections += 1
|
||||
}
|
||||
return state.section ?: throw BadRequestException("Не удалось определить раздел")
|
||||
}
|
||||
|
||||
private fun upsertArticle(
|
||||
section: ContentSection,
|
||||
p: ParsedArticle,
|
||||
body: String,
|
||||
state: ImportState,
|
||||
) {
|
||||
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
|
||||
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
|
||||
position = p.articlePos
|
||||
}
|
||||
articles.save(article)
|
||||
state.seenArticles.add(article.id)
|
||||
state.articles += 1
|
||||
}
|
||||
|
||||
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
|
||||
private fun removeMissing(state: ImportState): Int =
|
||||
state.touchedChapters.sumOf { chapterId ->
|
||||
sections
|
||||
.findByChapterIdOrderByPositionAscIdAsc(chapterId)
|
||||
.sumOf { section -> removeMissingInSection(section, state) }
|
||||
}
|
||||
|
||||
private fun removeMissingInSection(
|
||||
section: ContentSection,
|
||||
state: ImportState,
|
||||
): Int {
|
||||
if (section.id !in state.seenSections) {
|
||||
val removedCount = articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
|
||||
sections.delete(section)
|
||||
return removedCount
|
||||
}
|
||||
var removed = 0
|
||||
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
|
||||
if (article.id !in state.seenArticles) {
|
||||
articles.delete(article)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
|
||||
val linkRewrite = mutableMapOf<String, String>()
|
||||
for ((path, data) in assetFiles) {
|
||||
val name = path.substringAfterLast('/')
|
||||
if (name.isBlank()) continue
|
||||
val stored = assets.store(name, data)
|
||||
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
||||
}
|
||||
return linkRewrite
|
||||
}
|
||||
|
||||
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
|
||||
|
||||
private fun isContentPath(path: String): Boolean = (path.contains("/chapters/") || path.startsWith("chapters/")) && path.endsWith(".md")
|
||||
|
||||
private fun parseArticles(contentFiles: Map<String, ByteArray>): List<ParsedArticle> =
|
||||
contentFiles
|
||||
.map { (path, data) -> parseArticle(path, data) }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.chapterPos },
|
||||
{ it.chapterDir },
|
||||
{ it.sectionPos },
|
||||
{ it.sectionDir },
|
||||
{ it.articlePos },
|
||||
),
|
||||
)
|
||||
|
||||
private fun parseArticle(
|
||||
path: String,
|
||||
data: ByteArray,
|
||||
): ParsedArticle {
|
||||
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
||||
val parts = clean.split('/')
|
||||
if (parts.size < 3) {
|
||||
throw BadRequestException(
|
||||
"Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)",
|
||||
)
|
||||
}
|
||||
val chapterDir = parts[0]
|
||||
val sectionDir = parts[1]
|
||||
val fileName = parts.drop(2).joinToString("-")
|
||||
val (frontMatter, body) = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||
return ParsedArticle(
|
||||
chapterPos = Slugs.positionOf(chapterDir),
|
||||
chapterDir = Slugs.titleFromDir(chapterDir),
|
||||
sectionPos = Slugs.positionOf(sectionDir),
|
||||
sectionDir = Slugs.titleFromDir(sectionDir),
|
||||
articlePos = Slugs.positionOf(fileName),
|
||||
fileTitle = frontMatter["title"] ?: Slugs.titleFromDir(fileName),
|
||||
fmSlug = frontMatter["slug"],
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
|
||||
val result = mutableMapOf<String, ByteArray>()
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
|
||||
if (!content.startsWith("---")) return emptyMap<String, String>() to content
|
||||
val lines = content.lines()
|
||||
val end =
|
||||
lines.withIndex().firstOrNull { it.index != 0 && it.value.trim() == "---" }?.index
|
||||
?: return emptyMap<String, String>() to content
|
||||
val frontMatter = mutableMapOf<String, String>()
|
||||
for (line in lines.subList(1, end)) {
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) frontMatter[parts[0].trim()] = unquote(parts[1].trim())
|
||||
}
|
||||
return frontMatter to lines.drop(end + 1).joinToString("\n").trim()
|
||||
}
|
||||
|
||||
private fun unquote(value: String): String =
|
||||
if (value.length >= 2 && value.first() == '"' && value.last() == '"') {
|
||||
value.substring(1, value.length - 1)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
|
||||
private data class ParsedArticle(
|
||||
val chapterPos: Int,
|
||||
val chapterDir: String,
|
||||
val sectionPos: Int,
|
||||
val sectionDir: String,
|
||||
val articlePos: Int,
|
||||
val fileTitle: String,
|
||||
val fmSlug: String?,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
private class ImportState {
|
||||
var chapter: ContentChapter? = null
|
||||
var section: ContentSection? = null
|
||||
var chapters: Int = 0
|
||||
var sections: Int = 0
|
||||
var articles: Int = 0
|
||||
val touchedChapters: MutableSet<UUID> = mutableSetOf()
|
||||
val seenSections: MutableSet<UUID> = mutableSetOf()
|
||||
val seenArticles: MutableSet<UUID> = mutableSetOf()
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class ArticleTreeNode(
|
||||
val id: String,
|
||||
@@ -98,24 +96,12 @@ data class ReorderArticles(
|
||||
val articles: List<ReorderArticle> = emptyList(),
|
||||
)
|
||||
|
||||
private data class ParsedArticle(
|
||||
val path: String,
|
||||
val chapterPos: Int,
|
||||
val chapterDir: String,
|
||||
val sectionPos: Int,
|
||||
val sectionDir: String,
|
||||
val articlePos: Int,
|
||||
val fileTitle: String,
|
||||
val fmSlug: String?,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
@Service
|
||||
class ContentService(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
private val assets: AssetStorage,
|
||||
private val importer: ContentImporter,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
@@ -349,8 +335,8 @@ class ContentService(
|
||||
): ChapterTreeNode {
|
||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||
return ChapterTreeNode(
|
||||
@@ -390,8 +376,8 @@ class ContentService(
|
||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val sec =
|
||||
sections.save(
|
||||
@@ -505,7 +491,7 @@ class ContentService(
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val a =
|
||||
articles.save(
|
||||
@@ -547,297 +533,10 @@ class ContentService(
|
||||
)
|
||||
}
|
||||
|
||||
private fun uniqueSlug(
|
||||
title: String,
|
||||
taken: Set<String>,
|
||||
): String {
|
||||
val base = slugify(title) ?: "item"
|
||||
if (base !in taken) return base
|
||||
var i = 2
|
||||
while ("$base-$i" in taken) i += 1
|
||||
return "$base-$i"
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт zip со структурой каталогов:
|
||||
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
|
||||
* картинки в assets/.
|
||||
*/
|
||||
@Transactional
|
||||
fun importZip(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
sync: Boolean = true,
|
||||
): ContentImportResult {
|
||||
if (!filename.lowercase().endsWith(".zip")) {
|
||||
throw BadRequestException("Ожидается .zip архив")
|
||||
}
|
||||
val files = readZip(bytes)
|
||||
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
||||
|
||||
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
|
||||
val contentFiles =
|
||||
files.filterKeys { k ->
|
||||
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
|
||||
}
|
||||
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||
|
||||
val linkRewrite = mutableMapOf<String, String>()
|
||||
for ((path, data) in assetFiles) {
|
||||
val name = path.substringAfterLast('/')
|
||||
if (name.isBlank()) continue
|
||||
val stored = assets.store(name, data)
|
||||
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
||||
}
|
||||
|
||||
val parsed =
|
||||
contentFiles
|
||||
.map { (path, data) ->
|
||||
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
||||
val parts = clean.split('/')
|
||||
if (parts.size < 3) {
|
||||
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
|
||||
}
|
||||
val chapterDir = parts[0]
|
||||
val sectionDir = parts[1]
|
||||
val fileName = parts.drop(2).joinToString("-")
|
||||
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||
ParsedArticle(
|
||||
path = path,
|
||||
chapterPos = positionOf(chapterDir),
|
||||
chapterDir = titleFromDir(chapterDir),
|
||||
sectionPos = positionOf(sectionDir),
|
||||
sectionDir = titleFromDir(sectionDir),
|
||||
articlePos = positionOf(fileName),
|
||||
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
|
||||
fmSlug = parsedContent.first["slug"],
|
||||
body = parsedContent.second,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
{ it.chapterPos },
|
||||
{ it.chapterDir },
|
||||
{ it.sectionPos },
|
||||
{ it.sectionDir },
|
||||
{ it.articlePos },
|
||||
),
|
||||
)
|
||||
|
||||
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}"
|
||||
// Ищем по названию, затем по slug: так импорт находит главу, созданную
|
||||
// вручную или импортированную ранее, и не плодит дубли.
|
||||
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
|
||||
title = p.chapterDir
|
||||
position = p.chapterPos
|
||||
} ?: chapters.save(
|
||||
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)
|
||||
chCount += 1
|
||||
currentSection = null
|
||||
}
|
||||
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
||||
if (currentSection?.title != p.sectionDir) {
|
||||
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
||||
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 =
|
||||
uniqueSlug(
|
||||
p.sectionDir,
|
||||
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
|
||||
),
|
||||
position = p.sectionPos,
|
||||
),
|
||||
)
|
||||
currentSection = sections.save(currentSection!!)
|
||||
seenSections.add(currentSection!!.id)
|
||||
secCount += 1
|
||||
}
|
||||
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
||||
|
||||
var body = p.body
|
||||
for ((from, to) in linkRewrite) {
|
||||
body = body.replace(from, to)
|
||||
}
|
||||
val slug =
|
||||
p.fmSlug?.takeIf { it.isNotBlank() }
|
||||
?: articleSlug(p)
|
||||
?: "article-${artCount + 1}"
|
||||
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
|
||||
position = p.articlePos
|
||||
}
|
||||
articles.save(article)
|
||||
seenArticles.add(article.id)
|
||||
artCount += 1
|
||||
}
|
||||
|
||||
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
|
||||
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
|
||||
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
|
||||
for (chapterId in if (sync) touchedChapters else emptySet()) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
|
||||
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
|
||||
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
|
||||
|
||||
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
|
||||
|
||||
private fun articleSlug(p: ParsedArticle): String? = slugify(p.fileTitle)
|
||||
|
||||
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
|
||||
val result = mutableMapOf<String, ByteArray>()
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
result[entry.name.removePrefix("/")] = zis.readAllBytes()
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
|
||||
if (!content.startsWith("---")) return emptyMap<String, String>() to content
|
||||
val lines = content.lines()
|
||||
val end =
|
||||
lines
|
||||
.withIndex()
|
||||
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
|
||||
?.index ?: return emptyMap<String, String>() to content
|
||||
val fm = mutableMapOf<String, String>()
|
||||
for (line in lines.subList(1, end)) {
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val v = parts[1].trim()
|
||||
val unquoted =
|
||||
if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
|
||||
v.substring(1, v.length - 1)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
fm[parts[0].trim()] = unquoted
|
||||
}
|
||||
}
|
||||
return fm to lines.drop(end + 1).joinToString("\n").trim()
|
||||
}
|
||||
|
||||
private fun positionOf(name: String): Int {
|
||||
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
|
||||
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
|
||||
}
|
||||
|
||||
private fun titleFromDir(dir: String): String {
|
||||
val raw =
|
||||
dir
|
||||
.substringAfterLast('/')
|
||||
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||
.replace('_', ' ')
|
||||
return raw.ifBlank { dir }
|
||||
}
|
||||
|
||||
private fun slugify(value: String): String? {
|
||||
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
|
||||
val slug =
|
||||
translit
|
||||
.replace(Regex("\\s+"), "-")
|
||||
.replace(Regex("[^a-z0-9-]"), "")
|
||||
.replace(Regex("-+"), "-")
|
||||
.trim('-')
|
||||
return slug.ifBlank { null }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TRANSLIT =
|
||||
mapOf(
|
||||
"а" to "a",
|
||||
"б" to "b",
|
||||
"в" to "v",
|
||||
"г" to "g",
|
||||
"д" to "d",
|
||||
"е" to "e",
|
||||
"ё" to "e",
|
||||
"ж" to "zh",
|
||||
"з" to "z",
|
||||
"и" to "i",
|
||||
"й" to "y",
|
||||
"к" to "k",
|
||||
"л" to "l",
|
||||
"м" to "m",
|
||||
"н" to "n",
|
||||
"о" to "o",
|
||||
"п" to "p",
|
||||
"р" to "r",
|
||||
"с" to "s",
|
||||
"т" to "t",
|
||||
"у" to "u",
|
||||
"ф" to "f",
|
||||
"х" to "h",
|
||||
"ц" to "c",
|
||||
"ч" to "ch",
|
||||
"ш" to "sh",
|
||||
"щ" to "sch",
|
||||
"ъ" to "",
|
||||
"ы" to "y",
|
||||
"ь" to "",
|
||||
"э" to "e",
|
||||
"ю" to "yu",
|
||||
"я" to "ya",
|
||||
)
|
||||
}
|
||||
): ContentImportResult = importer.importZip(filename, bytes, sync)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
/** Транслитерация и построение slug для глав, разделов и статей. */
|
||||
object Slugs {
|
||||
private val TRANSLIT =
|
||||
mapOf(
|
||||
"а" to "a",
|
||||
"б" to "b",
|
||||
"в" to "v",
|
||||
"г" to "g",
|
||||
"д" to "d",
|
||||
"е" to "e",
|
||||
"ё" to "e",
|
||||
"ж" to "zh",
|
||||
"з" to "z",
|
||||
"и" to "i",
|
||||
"й" to "y",
|
||||
"к" to "k",
|
||||
"л" to "l",
|
||||
"м" to "m",
|
||||
"н" to "n",
|
||||
"о" to "o",
|
||||
"п" to "p",
|
||||
"р" to "r",
|
||||
"с" to "s",
|
||||
"т" to "t",
|
||||
"у" to "u",
|
||||
"ф" to "f",
|
||||
"х" to "h",
|
||||
"ц" to "c",
|
||||
"ч" to "ch",
|
||||
"ш" to "sh",
|
||||
"щ" to "sch",
|
||||
"ъ" to "",
|
||||
"ы" to "y",
|
||||
"ь" to "",
|
||||
"э" to "e",
|
||||
"ю" to "yu",
|
||||
"я" to "ya",
|
||||
)
|
||||
|
||||
/** «01-Ввод-вывод» -> «vvod-vyvod»; null, если не осталось допустимых символов. */
|
||||
fun slugify(value: String): String? {
|
||||
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
|
||||
val slug =
|
||||
translit
|
||||
.replace(Regex("\\s+"), "-")
|
||||
.replace(Regex("[^a-z0-9-]"), "")
|
||||
.replace(Regex("-+"), "-")
|
||||
.trim('-')
|
||||
return slug.ifBlank { null }
|
||||
}
|
||||
|
||||
/** Уникальный slug на основе заголовка: добавляет «-2», «-3», если базовый занят. */
|
||||
fun uniqueSlug(
|
||||
title: String,
|
||||
taken: Set<String>,
|
||||
): String {
|
||||
val base = slugify(title) ?: "item"
|
||||
if (base !in taken) return base
|
||||
var i = 2
|
||||
while ("$base-$i" in taken) i += 1
|
||||
return "$base-$i"
|
||||
}
|
||||
|
||||
/** Числовой префикс каталога («02-Матрицы» -> 2); 999, если префикса нет. */
|
||||
fun positionOf(name: String): Int {
|
||||
val match = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
|
||||
return match?.groupValues?.get(1)?.toIntOrNull() ?: 999
|
||||
}
|
||||
|
||||
/** «02-Матрицы» -> «Матрицы» (без числового префикса, подчёркивания → пробелы). */
|
||||
fun titleFromDir(dir: String): String {
|
||||
val raw =
|
||||
dir
|
||||
.substringAfterLast('/')
|
||||
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||
.replace('_', ' ')
|
||||
return raw.ifBlank { dir }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user