diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index 56d53a211..d0215f38e 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -84,30 +84,35 @@ class SyncChaptersWithSource( // to a higher value than newer chapters var maxSeenUploadDate: Instant? = null - for (sourceChapter in sourceChapters) { - var chapter = sourceChapter - - // Update metadata from source if necessary. - if (source is HttpSource) { + // Update metadata from source if necessary. + val preparedChapters = if (source is HttpSource) { + val sManga = manga.toSManga() + sourceChapters.map { chapter -> val sChapter = chapter.toSChapter() @Suppress("DEPRECATION") - source.prepareNewChapter(sChapter, manga.toSManga()) - chapter = chapter.copyFromSChapter(sChapter) + source.prepareNewChapter(sChapter, sManga) + chapter.copyFromSChapter(sChapter) + } + } else { + sourceChapters + } + + // Recognize chapter numbers, from the whole list since names alone don't always tell. + val recognizedChapters = preparedChapters + .zip(ChapterRecognition.parseChapterNumbers(manga.title, preparedChapters)) { chapter, numbering -> + chapter.copy(chapterNumber = numbering.chapter?.let(ChapterRecognition::toDouble) ?: -1.0) } + for (chapter in recognizedChapters) { if (!sourceUrls.add(chapter.url)) continue - // Recognize chapter number for the chapter. - val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapterNumber) - chapter = chapter.copy(chapterNumber = chapterNumber) - val dbChapter = dbChaptersByUrl[chapter.url] if (dbChapter == null) { val toAddChapter = if (chapter.dateUpload == null) { chapter.copy(dateUpload = maxSeenUploadDate ?: nowInstant) } else { - maxSeenUploadDate = listOfNotNull(maxSeenUploadDate, sourceChapter.dateUpload).maxOrNull() + maxSeenUploadDate = listOfNotNull(maxSeenUploadDate, chapter.dateUpload).maxOrNull() chapter } newChapters.add(toAddChapter) diff --git a/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt b/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt index 3190e0456..5d294f0dc 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt @@ -1,15 +1,29 @@ package tachiyomi.domain.chapter.service +import tachiyomi.domain.chapter.model.Chapter +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import kotlin.math.floor +import kotlin.math.round + /** + * Recognizes the volume and chapter number of chapters from their names. + * + * Each name is first read on its own: the volume or season is taken out, a part becomes the letter of a sub-chapter, + * and the chapter number is the number after "ch." or else the first one left. Reading the whole chapter list then + * fills in what single names don't tell, see [parseChapterNumbers]. + * * -R> = regex conversion. */ object ChapterRecognition { + /** + * A number with optional decimals and a trailing word or letter, e.g. 12, 12.5, 12a, 12.extra + */ private const val NUMBER_PATTERN = """([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""" /** - * All cases with Ch.xx - * Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4 + * Example: Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4 */ private val basic = Regex("""(?<=ch\.) *$NUMBER_PATTERN""") @@ -19,113 +33,301 @@ object ChapterRecognition { private val number = Regex(NUMBER_PATTERN) /** - * Regex used to remove unwanted tags - * Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12 + * Example: Onepunch-Man Punch Ver002 028 -R> Onepunch-Man Punch 028 */ - private val unwanted = Regex("""\b(?:v|ver|vol|version|volume|season|s)[^a-z]?[0-9]+""") + private val unwanted = Regex("""\b(?:ver|version)[^a-z]?[0-9]+""") /** - * Regex used to remove unwanted whitespace - * Example One Piece 12 special -R> One Piece 12special + * Example: One Piece 12 special -R> One Piece 12special */ private val unwantedWhiteSpace = Regex("""\s(?=extra|special|omake)""") + /** + * A volume or season, the short forms only right before the number since "s" also ends words. + * Example: Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 1, The Gamer S3 - Chapter 20 -R> 3 + */ + private val volume = Regex("""\b(?:(?:vol(?:ume)?|season)[^a-z0-9]? *|[vs]\.?)([0-9]+(?:\.[0-9]+)?)\b""") + + /** + * Example: Chapter 1.5 Part II -R> b + */ + private val part = Regex("""\bpart\.? *([0-9]+|i{1,3}|iv|vi{0,3}|ix|x)\b""") + + private val romanNumerals = listOf("i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x") + + /** + * Example: Group 2 Chapter 386 -R> Group # Chapter # + */ + private val anyNumber = Regex("""[0-9]+(?:\.[0-9]+)?""") + + /** + * A number after this is the chapter number, even when every similar name has the same one. + * Example: Oshi no Ko Chapter 5 Part 1 -R> 5a + */ + private val chapterPrefix = Regex("""(?:\b(?:ch|chap|chapter|ep|episode)\.? *|#)$""") + + /** + * Example: Bonus Chapter: Beach Episode + */ + private val bonus = Regex("""\b(?:extra|bonus|omake|special|side ?story|afterword|interlude)""") + + /** + * Example: Prologue: The Beginning -R> 0 + */ + private val prologue = Regex("""\b(?:prologue|one.?shot|pilot)\b""") + + /** + * Formats numbers without trailing zeros and with a dot whatever the locale, e.g. 10.5, 3, 24.005 + */ + private val formatter = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' }) + + /** + * The volume and chapter number recognized for a chapter, e.g. "10.5" or "19a", `null` when there is none. + */ + data class ChapterNumbering(val volume: String?, val chapter: String?) + + /** + * A recognized number, with the letter of a sub-chapter like the a in 19a. Kept apart from the value so that + * chapters can still be compared and numbered after each other. + */ + private data class RecognizedNumber(val value: Double, val letter: Char? = null) { + override fun toString(): String = formatter.format(value) + (letter?.toString() ?: "") + } + + /** + * Recognizes the chapter number of a single chapter, keeping [chapterNumber] when the source gave one. + */ fun parseChapterNumber( mangaTitle: String, chapterName: String, chapterNumber: Double? = null, ): Double { - // If chapter number is known return. - if (chapterNumber != null && (chapterNumber == -2.0 || chapterNumber > -1.0)) { - return chapterNumber + if (chapterNumber != null && chapterNumber.isKnown()) return chapterNumber + return parseName(cleanName(mangaTitle, chapterName))?.let { toDouble(it.toString()) } ?: chapterNumber ?: -1.0 + } + + /** + * Recognizes the numbers of [chapters], given from the most to the least recent, using the whole list for what a + * name alone doesn't tell: numbers every similar name shares aren't the chapter number, and chapters without a + * number are numbered after the chapters around them. Numbers the source gave are kept. + */ + fun parseChapterNumbers(mangaTitle: String, chapters: List): List { + val names = chapters.map { cleanName(mangaTitle, it.name) } + // Taken out before reading the names, so a number like the 2 in "Group 2 Chapter 386" can't be picked + val sharedNumbers = findSharedNumbers(names, chapters) + val chapterNumbers = Array(chapters.size) { index -> + chapters[index].chapterNumber.takeIf { it.isKnown() }?.let(::RecognizedNumber) + ?: parseName(names[index].removeRanges(sharedNumbers[index])) } + // Only from the name: newer chapters may not be in a volume yet, so neighbours don't tell + val volumes = Array(chapters.size) { volume.find(names[it])?.groupValues?.get(1)?.toDouble() } - // Get chapter title with lower case - val cleanChapterName = chapterName.lowercase() - // Remove manga title from chapter title. - .replace(mangaTitle.lowercase(), "").trim() - // Remove comma's or hyphens. - .replace(',', '.') - .replace('-', '.') - // Remove unwanted white spaces. - .replace(unwantedWhiteSpace, "") + fillChapterNumbers(names, chapterNumbers, volumes) + return chapters.indices.map { + ChapterNumbering(volumes[it]?.let(formatter::format), chapterNumbers[it]?.toString()) + } + } - val numberMatch = number.findAll(cleanChapterName) + /** + * Returns [number], a number of a [ChapterNumbering], as a double. A letter only counts without decimals, from .1 + * for a up to .9 for i, e.g. 19a -R> 19.1 and 1.5a -R> 1.5. + */ + fun toDouble(number: String): Double { + val letter = number.last().takeIf { it.isLetter() } + val value = number.removeSuffix(letter?.toString() ?: "").toDouble() + val tenths = letter?.let { it - 'a' + 1 } + return if (tenths != null && tenths < 10 && '.' !in number) value + tenths / 10.0 else value + } - when { - numberMatch.none() -> { - return chapterNumber ?: -1.0 + /** + * Whether the source gave a number: -1 and below mean it didn't, except -2 which some sources use on purpose. + */ + private fun Double.isKnown(): Boolean = this == -2.0 || this > -1.0 + + private fun cleanName(mangaTitle: String, chapterName: String): String { + return chapterName.lowercase().replace(mangaTitle.lowercase(), "") + } + + /** + * Replaces [ranges] with spaces, from the last so the earlier ones stay where they are. + */ + private fun String.removeRanges(ranges: List): String { + return ranges.sortedByDescending { it.first }.fold(this) { name, range -> name.replaceRange(range, " ") } + } + + /** + * Finds the numbers that are the same in every name of a group of at least three names that only differ in their + * numbers, e.g. the 2 in "Group 2 Chapter 385". Chapters with a number from the source are left out. + */ + private fun findSharedNumbers(names: List, chapters: List): List> { + val matches = names.map { anyNumber.findAll(it).toList() } + val sharedNumbers = MutableList(names.size) { emptyList() } + names.indices + .filterNot { chapters[it].chapterNumber.isKnown() } + // Grouped by all but their numbers, so that the nth number of each name means the same thing + .groupBy { names[it].replace(anyNumber, "#") } + .values + // Fewer names could share a number by chance + .filter { it.size >= 3 } + .forEach { group -> + val first = matches[group.first()] + val (sharedSlots, otherSlots) = first.indices.partition { slot -> + group.all { matches[it][slot].value == first[slot].value } + } + // Something has to be left to tell the chapters apart + if (otherSlots.isEmpty()) return@forEach + // Parts of a chapter share its number, e.g. the 5 of "Chapter 5 Part 1" and "Chapter 5 Part 2" + val removableSlots = sharedSlots.filterNot { slot -> + chapterPrefix.containsMatchIn(names[group.first()].substring(0, first[slot].range.first)) + } + group.forEach { index -> sharedNumbers[index] = removableSlots.map { matches[index][it].range } } } - numberMatch.count() > 1 -> { - // Remove unwanted tags. - unwanted.replace(cleanChapterName, "").let { name -> - // Check base case ch.xx - basic.find(name)?.let { return getChapterNumberFromMatch(it) } + return sharedNumbers + } - // need to find again first number might already removed - number.find(name)?.let { return getChapterNumberFromMatch(it) } + /** + * Numbers runs of chapters without a number after the chapters around them, only within a volume since chapter + * numbers may start over in the next one: + * - between two chapters, the missing whole numbers when they fit, e.g. 2, ?, 4 -R> 3, or else 10, ?, 11 -R> 10.5 + * - after the newest chapter, only bonus chapters, e.g. 100, Afterword -R> 100.5 + * - before the oldest chapter, only a prologue, which gets 0, and volume 0 when the chapter after it has a volume + */ + private fun fillChapterNumbers( + names: List, + chapterNumbers: Array, + volumes: Array, + ) { + chapterNumbers.nullRuns().forEach { run -> + // A -2 from the source says nothing about where a chapter is, so it can't be numbered after + val newer = chapterNumbers.getOrNull(run.first - 1)?.takeIf { it.value >= 0 } + val older = chapterNumbers.getOrNull(run.last + 1)?.takeIf { it.value >= 0 } + val olderVolume = volumes.getOrNull(run.last + 1) + val bonuses = run.count { bonus.containsMatchIn(names[it]) } + when { + newer != null && older != null -> { + val isSameVolume = volumes[run.first - 1] == olderVolume && volumes.isWithinVolume(run, olderVolume) + // Out of order chapters tell nothing about the ones between them + if (newer.value <= older.value || !isSameVolume) return@forEach + val isWhole = listOf(newer, older).all { it.letter == null && it.value % 1.0 == 0.0 } + // Bonus chapters aren't missing chapters even when they would fit, e.g. 2, Bonus, 4 -R> 2.5 + if (bonuses == 0 && isWhole && newer.value - older.value - 1 == run.count().toDouble()) { + run.forEach { chapterNumbers[it] = RecognizedNumber(older.value + (run.last + 1 - it)) } + } else { + // Never past the next whole number, which may still be a chapter to come + chapterNumbers.fillBetween(run, older.value, minOf(newer.value, floor(older.value) + 1)) + } + } + older != null -> { + // Newer chapters without a number may as well be announcements + if (bonuses < run.count() || !volumes.isWithinVolume(run, olderVolume)) return@forEach + chapterNumbers.fillBetween(run, older.value, floor(older.value) + 1) + } + // When the first chapter is already 0, that's the prologue + newer != null && newer.value > 0 -> { + run.filter { prologue.containsMatchIn(names[it]) }.forEach { + chapterNumbers[it] = RecognizedNumber(0.0) + if (volumes[it] == null && volumes[run.first - 1] != null) volumes[it] = 0.0 + } } } } - - // return the first number encountered - return getChapterNumberFromMatch(numberMatch.first()) } /** - * Check if chapter number is found and return it - * @param match result of regex - * @return chapter number if found else null + * Returns the ranges of consecutive chapters without a number. */ - private fun getChapterNumberFromMatch(match: MatchResult): Double { - return match.let { - val initial = it.groups[1]?.value?.toDouble()!! - val subChapterDecimal = it.groups[2]?.value - val subChapterAlpha = it.groups[3]?.value - val addition = checkForDecimal(subChapterDecimal, subChapterAlpha) - initial.plus(addition) + private fun Array.nullRuns(): List { + val runs = mutableListOf() + var start = 0 + while (start < size) { + if (this[start] != null) { + start++ + continue + } + val end = (start...isWithinVolume(run: IntRange, volume: Double?): Boolean { + return run.all { this[it] == null || this[it] == volume } } /** - * x.a -> x.1, x.b -> x.2, etc + * Numbers [run] between [lower] and [upper], the least recent getting the lowest: .5, .6 and so on after the whole + * number, e.g. 10, ?, ?, 11 -R> 10.5, 10.6, or evenly spread when those don't fit. */ - private fun parseAlphaPostFix(alpha: Char): Double { - val number = alpha.code - ('a'.code - 1) - if (number >= 10) return 0.0 - return number / 10.0 + private fun Array.fillBetween(run: IntRange, lower: Double, upper: Double) { + val tenths = (5..9).map { floor(lower) + it / 10.0 }.filter { it > lower && it < upper } + if (tenths.size >= run.count()) { + // Rounded since tenths can't be exact doubles, e.g. 10 + 0.6 + run.reversed().forEachIndexed { index, chapter -> + this[chapter] = RecognizedNumber(round(tenths[index] * 10) / 10) + } + return + } + // Tenths run out after e.g. a 2.9 or past five chapters, and the chapters still have to fit below upper + val step = (upper - lower) / (run.count() + 1) + run.forEach { this[it] = RecognizedNumber(round((lower + step * (run.last + 1 - it)) * 1000) / 1000) } + } + + /** + * Returns the chapter number in [name], a chapter name with the manga title removed, or `null` if it has none. A + * part after the chapter number becomes its letter, e.g. Chapter 1.5 Part I -R> 1.5a. + */ + private fun parseName(name: String): RecognizedNumber? { + // The volume can't also be the chapter number + val withoutVolume = volume.replace(name, " ") + val partMatch = part.find(withoutVolume) + val partLetter = partMatch?.groupValues?.get(1)?.let(::parsePart)?.takeIf { it in 1..26 }?.let { 'a' + it - 1 } + if (partMatch != null && partLetter != null) { + parseNumber(withoutVolume.removeRange(partMatch.range))?.let { number -> + // A letter written in the number is what the source numbers the chapter by + return if (number.letter == null) number.copy(letter = partLetter) else number + } + } + // Without another number, e.g. "Part 3", the part is the chapter number + return parseNumber(withoutVolume) + } + + private fun parsePart(part: String): Int = part.toIntOrNull() ?: (romanNumerals.indexOf(part) + 1) + + /** + * Returns the number after "ch." or else the first one, leaving out version tags when there are several. + */ + private fun parseNumber(name: String): RecognizedNumber? { + val cleanName = name + .trim() + // Some sources write decimals and letters with these, e.g. 300,a and 122-a + .replace(',', '.') + .replace('-', '.') + .replace(unwantedWhiteSpace, "") + + val numbers = number.findAll(cleanName).toList() + if (numbers.size > 1) { + val withoutTags = unwanted.replace(cleanName, "") + (basic.find(withoutTags) ?: number.find(withoutTags))?.let { return it.toRecognizedNumber() } + } + return numbers.firstOrNull()?.toRecognizedNumber() + } + + /** + * Example: 12.5 -R> 12.5, 12.extra -R> 12.99, 12.a -R> 12a + */ + private fun MatchResult.toRecognizedNumber(): RecognizedNumber { + val (whole, decimal, alpha) = destructured + val value = whole.toDouble() + (decimal.toDoubleOrNull() ?: 0.0) + return when { + decimal.isEmpty() && "extra" in alpha -> RecognizedNumber(value + 0.99) + decimal.isEmpty() && "omake" in alpha -> RecognizedNumber(value + 0.98) + decimal.isEmpty() && "special" in alpha -> RecognizedNumber(value + 0.97) + // Only up to i, like the decimals they used to stand for, since others are rather words like 5v2 + else -> RecognizedNumber(value, alpha.trimStart('.').singleOrNull()?.takeIf { it in 'a'..'i' }) + } } } diff --git a/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt b/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt index 8369bfa5c..b7830ba15 100644 --- a/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt +++ b/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt @@ -4,160 +4,149 @@ import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode +import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.chapter.service.ChapterRecognition.ChapterNumbering @Execution(ExecutionMode.CONCURRENT) class ChapterRecognitionTest { @Test fun `Basic Ch prefix`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4: Misrepresentation", 4.0) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4: Misrepresentation" to ChapterNumbering("1", "4"), + ) } @Test fun `Basic Ch prefix with space after period`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol. 1 Ch. 4: Misrepresentation", 4.0) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol. 1 Ch. 4: Misrepresentation" to ChapterNumbering("1", "4"), + ) } @Test fun `Basic Ch prefix with decimal`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.1: Misrepresentation", 4.1) - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.4: Misrepresentation", 4.4) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.1: Misrepresentation" to ChapterNumbering("1", "4.1"), + ) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.4: Misrepresentation" to ChapterNumbering("1", "4.4"), + ) } @Test fun `Basic Ch prefix with alpha postfix`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.a: Misrepresentation", 4.1) - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.b: Misrepresentation", 4.2) - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.extra: Misrepresentation", 4.99) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.a: Misrepresentation" to ChapterNumbering("1", "4a"), + ) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.b: Misrepresentation" to ChapterNumbering("1", "4b"), + ) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.extra: Misrepresentation" to ChapterNumbering("1", "4.99"), + ) } @Test fun `Name containing one number`() { - val mangaTitle = "Bleach" - - assertChapter(mangaTitle, "Bleach 567 Down With Snowwhite", 567.0) + assertNumbers("Bleach", "Bleach 567 Down With Snowwhite" to ChapterNumbering(null, "567")) } @Test fun `Name containing one number and decimal`() { - val mangaTitle = "Bleach" - - assertChapter(mangaTitle, "Bleach 567.1 Down With Snowwhite", 567.1) - assertChapter(mangaTitle, "Bleach 567.4 Down With Snowwhite", 567.4) + assertNumbers("Bleach", "Bleach 567.1 Down With Snowwhite" to ChapterNumbering(null, "567.1")) + assertNumbers("Bleach", "Bleach 567.4 Down With Snowwhite" to ChapterNumbering(null, "567.4")) } @Test fun `Name containing one number and alpha`() { - val mangaTitle = "Bleach" - - assertChapter(mangaTitle, "Bleach 567.a Down With Snowwhite", 567.1) - assertChapter(mangaTitle, "Bleach 567.b Down With Snowwhite", 567.2) - assertChapter(mangaTitle, "Bleach 567.extra Down With Snowwhite", 567.99) + assertNumbers("Bleach", "Bleach 567.a Down With Snowwhite" to ChapterNumbering(null, "567a")) + assertNumbers("Bleach", "Bleach 567.b Down With Snowwhite" to ChapterNumbering(null, "567b")) + assertNumbers("Bleach", "Bleach 567.extra Down With Snowwhite" to ChapterNumbering(null, "567.99")) } @Test fun `Chapter containing manga title and number`() { - val mangaTitle = "Solanin" - - assertChapter(mangaTitle, "Solanin 028 Vol. 2", 28.0) + assertNumbers("Solanin", "Solanin 028 Vol. 2" to ChapterNumbering("2", "28")) } @Test fun `Chapter containing manga title and number decimal`() { - val mangaTitle = "Solanin" - - assertChapter(mangaTitle, "Solanin 028.1 Vol. 2", 28.1) - assertChapter(mangaTitle, "Solanin 028.4 Vol. 2", 28.4) + assertNumbers("Solanin", "Solanin 028.1 Vol. 2" to ChapterNumbering("2", "28.1")) + assertNumbers("Solanin", "Solanin 028.4 Vol. 2" to ChapterNumbering("2", "28.4")) } @Test fun `Chapter containing manga title and number alpha`() { - val mangaTitle = "Solanin" - - assertChapter(mangaTitle, "Solanin 028.a Vol. 2", 28.1) - assertChapter(mangaTitle, "Solanin 028.b Vol. 2", 28.2) - assertChapter(mangaTitle, "Solanin 028.extra Vol. 2", 28.99) + assertNumbers("Solanin", "Solanin 028.a Vol. 2" to ChapterNumbering("2", "28a")) + assertNumbers("Solanin", "Solanin 028.b Vol. 2" to ChapterNumbering("2", "28b")) + assertNumbers("Solanin", "Solanin 028.extra Vol. 2" to ChapterNumbering("2", "28.99")) } @Test fun `Extreme case`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028", 28.0) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028" to ChapterNumbering(null, "28")) } @Test fun `Extreme case with decimal`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.1", 28.1) - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.4", 28.4) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.1" to ChapterNumbering(null, "28.1")) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.4" to ChapterNumbering(null, "28.4")) } @Test fun `Extreme case with alpha`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.a", 28.1) - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.b", 28.2) - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.extra", 28.99) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.a" to ChapterNumbering(null, "28a")) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.b" to ChapterNumbering(null, "28b")) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.extra" to ChapterNumbering(null, "28.99")) } @Test fun `Chapter containing dot v2`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Vol.1 Ch.5v.2: Alones", 5.0) + assertNumbers("random", "Vol.1 Ch.5v.2: Alones" to ChapterNumbering("1", "5")) } @Test fun `Number in manga title`() { - val mangaTitle = "Ayame 14" - - assertChapter(mangaTitle, "Ayame 14 1 - The summer of 14", 1.0) + assertNumbers("Ayame 14", "Ayame 14 1 - The summer of 14" to ChapterNumbering(null, "1")) } @Test fun `Space between ch x`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation", 4.0) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation" to ChapterNumbering("1", "4"), + ) } @Test fun `Chapter title with ch substring`() { - val mangaTitle = "Ayame 14" - - assertChapter(mangaTitle, "Vol.1 Ch.1: March 25 (First Day Cohabiting)", 1.0) + assertNumbers("Ayame 14", "Vol.1 Ch.1: March 25 (First Day Cohabiting)" to ChapterNumbering("1", "1")) } @Test fun `Chapter containing multiple zeros`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Vol.001 Ch.003: Kaguya Doesn't Know Much", 3.0) + assertNumbers("random", "Vol.001 Ch.003: Kaguya Doesn't Know Much" to ChapterNumbering("1", "3")) } @Test fun `Chapter with version before number`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 086 : Creeping Darkness [3]", 86.0) + assertNumbers( + "Onepunch-Man", + "Onepunch-Man Punch Ver002 086 : Creeping Darkness [3]" to ChapterNumbering(null, "86"), + ) } @Test fun `Version attached to chapter number`() { - val mangaTitle = "Ansatsu Kyoushitsu" - - assertChapter(mangaTitle, "Ansatsu Kyoushitsu 011v002: Assembly Time", 11.0) + assertNumbers("Ansatsu Kyoushitsu", "Ansatsu Kyoushitsu 011v002: Assembly Time" to ChapterNumbering(null, "11")) } /** @@ -166,9 +155,7 @@ class ChapterRecognitionTest { */ @Test fun `Number after manga title with chapter in chapter title case`() { - val mangaTitle = "Tokyo ESP" - - assertChapter(mangaTitle, "Tokyo ESP 027: Part 002: Chapter 001", 027.0) + assertNumbers("Tokyo ESP", "Tokyo ESP 027: Part 002: Chapter 001" to ChapterNumbering(null, "27b")) } /** @@ -177,101 +164,276 @@ class ChapterRecognitionTest { */ @Test fun `Number after unwanted tag`() { - val mangaTitle = "One-punch Man" - - assertChapter(mangaTitle, "Mag Version 195.5", 195.5) + assertNumbers("One-punch Man", "Mag Version 195.5" to ChapterNumbering(null, "195.5")) } @Test fun `Unparseable chapter`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Foo", -1.0) + assertNumbers("random", "Foo" to ChapterNumbering(null, null)) } @Test fun `Chapter with time in title`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Fairy Tail 404: 00:00", 404.0) + assertNumbers("random", "Fairy Tail 404: 00:00" to ChapterNumbering(null, "404")) } @Test fun `Chapter with alpha without dot`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Asu No Yoichi 19a", 19.1) + assertNumbers("random", "Asu No Yoichi 19a" to ChapterNumbering(null, "19a")) } @Test fun `Chapter title containing extra and vol`() { - val mangaTitle = "Fairy Tail" - - assertChapter(mangaTitle, "Fairy Tail 404.extravol002", 404.99) - assertChapter(mangaTitle, "Fairy Tail 404 extravol002", 404.99) + assertNumbers("Fairy Tail", "Fairy Tail 404.extravol002" to ChapterNumbering(null, "404.99")) + assertNumbers("Fairy Tail", "Fairy Tail 404 extravol002" to ChapterNumbering(null, "404.99")) } @Test fun `Chapter title containing omake (japanese extra) and vol`() { - val mangaTitle = "Fairy Tail" - - assertChapter(mangaTitle, "Fairy Tail 404.omakevol002", 404.98) - assertChapter(mangaTitle, "Fairy Tail 404 omakevol002", 404.98) + assertNumbers("Fairy Tail", "Fairy Tail 404.omakevol002" to ChapterNumbering(null, "404.98")) + assertNumbers("Fairy Tail", "Fairy Tail 404 omakevol002" to ChapterNumbering(null, "404.98")) } @Test fun `Chapter title containing special and vol`() { - val mangaTitle = "Fairy Tail" - - assertChapter(mangaTitle, "Fairy Tail 404.specialvol002", 404.97) - assertChapter(mangaTitle, "Fairy Tail 404 specialvol002", 404.97) + assertNumbers("Fairy Tail", "Fairy Tail 404.specialvol002" to ChapterNumbering(null, "404.97")) + assertNumbers("Fairy Tail", "Fairy Tail 404 specialvol002" to ChapterNumbering(null, "404.97")) } @Test fun `Chapter title containing commas`() { - val mangaTitle = "One Piece" - - assertChapter(mangaTitle, "One Piece 300,a", 300.1) - assertChapter(mangaTitle, "One Piece Ch,123,extra", 123.99) - assertChapter(mangaTitle, "One Piece the sunny, goes swimming 024,005", 24.005) + assertNumbers("One Piece", "One Piece 300,a" to ChapterNumbering(null, "300a")) + assertNumbers("One Piece", "One Piece Ch,123,extra" to ChapterNumbering(null, "123.99")) + assertNumbers("One Piece", "One Piece the sunny, goes swimming 024,005" to ChapterNumbering(null, "24.005")) } @Test fun `Chapter title containing hyphens`() { - val mangaTitle = "Solo Leveling" - - assertChapter(mangaTitle, "ch 122-a", 122.1) - assertChapter(mangaTitle, "Solo Leveling Ch.123-extra", 123.99) - assertChapter(mangaTitle, "Solo Leveling, 024-005", 24.005) - assertChapter(mangaTitle, "Ch.191-200 Read Online", 191.200) + assertNumbers("Solo Leveling", "ch 122-a" to ChapterNumbering(null, "122a")) + assertNumbers("Solo Leveling", "Solo Leveling Ch.123-extra" to ChapterNumbering(null, "123.99")) + assertNumbers("Solo Leveling", "Solo Leveling, 024-005" to ChapterNumbering(null, "24.005")) + assertNumbers("Solo Leveling", "Ch.191-200 Read Online" to ChapterNumbering(null, "191.2")) } @Test fun `Chapters containing season`() { - assertChapter("D.I.C.E", "D.I.C.E[Season 001] Ep. 007", 7.0) + assertNumbers("D.I.C.E", "D.I.C.E[Season 001] Ep. 007" to ChapterNumbering("1", "7")) } @Test fun `Chapters in format sx - chapter xx`() { - assertChapter("The Gamer", "S3 - Chapter 20", 20.0) + assertNumbers("The Gamer", "S3 - Chapter 20" to ChapterNumbering("3", "20")) } @Test fun `Chapters ending with s`() { - assertChapter("One Outs", "One Outs 001", 1.0) + assertNumbers("One Outs", "One Outs 001" to ChapterNumbering(null, "1")) } @Test fun `Chapters containing ordinals`() { val mangaTitle = "The Sister of the Woods with a Thousand Young" - assertChapter(mangaTitle, "The 1st Night", 1.0) - assertChapter(mangaTitle, "The 2nd Night", 2.0) - assertChapter(mangaTitle, "The 3rd Night", 3.0) - assertChapter(mangaTitle, "The 4th Night", 4.0) + assertNumbers(mangaTitle, "The 1st Night" to ChapterNumbering(null, "1")) + assertNumbers(mangaTitle, "The 2nd Night" to ChapterNumbering(null, "2")) + assertNumbers(mangaTitle, "The 3rd Night" to ChapterNumbering(null, "3")) + assertNumbers(mangaTitle, "The 4th Night" to ChapterNumbering(null, "4")) } - private fun assertChapter(mangaTitle: String, name: String, expected: Double) { - ChapterRecognition.parseChapterNumber(mangaTitle, name) shouldBe expected + @Test + fun `Parts are letters`() { + assertNumbers("Chapter 1.5 Part I" to ChapterNumbering(null, "1.5a")) + assertNumbers("Chapter 1.5 Part II" to ChapterNumbering(null, "1.5b")) + assertNumbers("Chapter 12 Part 3" to ChapterNumbering(null, "12c")) + assertNumbers("Vol.2 Chapter 7 - Part IV" to ChapterNumbering("2", "7d")) + assertNumbers("Part 3" to ChapterNumbering(null, "3")) + } + + @Test + fun `Numbers are turned into doubles`() { + ChapterRecognition.toDouble("12") shouldBe 12.0 + ChapterRecognition.toDouble("10.5") shouldBe 10.5 + ChapterRecognition.toDouble("19a") shouldBe 19.1 + ChapterRecognition.toDouble("19i") shouldBe 19.9 + ChapterRecognition.toDouble("1.5a") shouldBe 1.5 + ChapterRecognition.toDouble("-2") shouldBe -2.0 + } + + @Test + fun `Volume is not taken as the chapter number`() { + assertNumbers("Mokushiroku Alice", "Mokushiroku Alice Vol.3" to ChapterNumbering("3", null)) + assertNumbers("Mokushiroku Alice", "Vol.3 12" to ChapterNumbering("3", "12")) + } + + @Test + fun `Seasons are volumes`() { + assertNumbers("Vol-3 Chapter 2" to ChapterNumbering("3", "2")) + assertNumbers("Mob's 100" to ChapterNumbering(null, "100")) + } + + @Test + fun `Volumes are only taken from the name`() { + assertNumbers( + "Vol.2 Ch.5" to ChapterNumbering("2", "5"), + "Ch.4" to ChapterNumbering(null, "4"), + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + ) + } + + @Test + fun `Numbers every similar name shares are ignored`() { + assertNumbers( + "Group 2 Chapter 387" to ChapterNumbering(null, "387"), + "Group 2 Chapter 386" to ChapterNumbering(null, "386"), + "Group 2 Chapter 385" to ChapterNumbering(null, "385"), + ) + } + + @Test + fun `Shared numbers after a chapter prefix are kept`() { + assertNumbers( + "Chapter 5 Part 3" to ChapterNumbering(null, "5c"), + "Chapter 5 Part 2" to ChapterNumbering(null, "5b"), + "Chapter 5 Part 1" to ChapterNumbering(null, "5a"), + ) + } + + @Test + fun `Chapters without a number fill the missing whole numbers`() { + assertNumbers( + "Chapter 4" to ChapterNumbering(null, "4"), + "The Return" to ChapterNumbering(null, "3"), + "Chapter 2" to ChapterNumbering(null, "2"), + ) + assertNumbers( + "Chapter 5" to ChapterNumbering(null, "5"), + "Rain" to ChapterNumbering(null, "4"), + "Snow" to ChapterNumbering(null, "3"), + "Chapter 2" to ChapterNumbering(null, "2"), + ) + } + + @Test + fun `Chapters without a number go between the chapters around them`() { + assertNumbers( + "Chapter 11" to ChapterNumbering(null, "11"), + "Bonus: Beach Episode" to ChapterNumbering(null, "10.5"), + "Chapter 10" to ChapterNumbering(null, "10"), + ) + assertNumbers( + "Chapter 13" to ChapterNumbering(null, "13"), + "Intermission" to ChapterNumbering(null, "10.5"), + "Chapter 10" to ChapterNumbering(null, "10"), + ) + assertNumbers( + "Chapter 11" to ChapterNumbering(null, "11"), + "Rain" to ChapterNumbering(null, "10.6"), + "Snow" to ChapterNumbering(null, "10.5"), + "Chapter 10" to ChapterNumbering(null, "10"), + ) + assertNumbers( + "Chapter 4" to ChapterNumbering(null, "4"), + "Side Story" to ChapterNumbering(null, "2.5"), + "Chapter 2" to ChapterNumbering(null, "2"), + ) + assertNumbers( + "Chapter 3" to ChapterNumbering(null, "3"), + "Extra" to ChapterNumbering(null, "2.6"), + "Chapter 2.5" to ChapterNumbering(null, "2.5"), + ) + assertNumbers( + "Chapter 3" to ChapterNumbering(null, "3"), + "Extra" to ChapterNumbering(null, "2.95"), + "Chapter 2.9" to ChapterNumbering(null, "2.9"), + ) + } + + @Test + fun `Chapters without a number at the ends`() { + assertNumbers( + "Afterword" to ChapterNumbering(null, "100.5"), + "Chapter 100" to ChapterNumbering(null, "100"), + ) + assertNumbers( + "Hiatus Announcement" to ChapterNumbering(null, null), + "Chapter 100" to ChapterNumbering(null, "100"), + ) + assertNumbers( + "Chapter 1" to ChapterNumbering(null, "1"), + "Prologue" to ChapterNumbering(null, "0"), + ) + assertNumbers( + "Chapter 1" to ChapterNumbering(null, "1"), + "Character Profiles" to ChapterNumbering(null, null), + ) + } + + @Test + fun `A prologue before chapters in volumes is in volume 0`() { + assertNumbers( + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + "Prologue" to ChapterNumbering("0", "0"), + ) + assertNumbers( + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + "Vol.2 Prologue" to ChapterNumbering("2", "0"), + ) + } + + @Test + fun `Chapters without a number are only numbered within a volume`() { + assertNumbers( + "Vol.1 Ch.3" to ChapterNumbering("1", "3"), + "Rain" to ChapterNumbering(null, "2"), + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + ) + assertNumbers( + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + "Rain" to ChapterNumbering(null, null), + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + ) + assertNumbers( + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + "Vol.2 Rain" to ChapterNumbering("2", null), + "Ch.1" to ChapterNumbering(null, "1"), + ) + assertNumbers( + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + "Vol.1 Rain" to ChapterNumbering("1", null), + "Vol.2 Ch.1" to ChapterNumbering("2", "1"), + ) + assertNumbers( + "Extra" to ChapterNumbering(null, "10.5"), + "Vol.2 Ch.10" to ChapterNumbering("2", "10"), + ) + assertNumbers( + "Vol.2 Extra" to ChapterNumbering("2", "10.5"), + "Vol.2 Ch.10" to ChapterNumbering("2", "10"), + ) + assertNumbers( + "Vol.3 Extra" to ChapterNumbering("3", null), + "Vol.2 Ch.10" to ChapterNumbering("2", "10"), + ) + } + + @Test + fun `Numbers from the source are kept`() { + val chapters = listOf( + Chapter.create().copy(name = "Vol.1 Chapter 3", chapterNumber = 7.0), + Chapter.create().copy(name = "Chapter 2", chapterNumber = -1.0), + ) + + ChapterRecognition.parseChapterNumbers("", chapters) shouldBe + listOf(ChapterNumbering("1", "7"), ChapterNumbering(null, "2")) + } + + private fun assertNumbers(vararg expected: Pair) = assertNumbers("", *expected) + + /** + * Asserts the numbers recognized for chapters with the given names, listed from the most to the least recent. + */ + private fun assertNumbers(mangaTitle: String, vararg expected: Pair) { + val chapters = expected.map { (name, _) -> Chapter.create().copy(name = name, chapterNumber = -1.0) } + ChapterRecognition.parseChapterNumbers(mangaTitle, chapters) shouldBe expected.map { it.second } } }