Replace SImpleXML with KonsumeXML and KotlinXMLBuilder

This commit is contained in:
Shinokuni 2021-09-22 22:50:54 +02:00
parent 4c86b17043
commit 71df7a7a31
7 changed files with 122 additions and 198 deletions

View File

@ -56,6 +56,7 @@ dependencies {
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
implementation 'org.redundent:kotlin-xml-builder:1.7.3'
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
@ -66,7 +67,6 @@ dependencies {
exclude group: 'com.squareup.moshi', module: 'moshi'
}
implementation 'com.squareup.retrofit2:converter-simplexml:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'com.squareup.moshi:moshi:1.12.0'

View File

@ -0,0 +1,77 @@
package com.readrops.api.opml
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import java.lang.Exception
class OPMLAdapter : XmlAdapter<Map<Folder?, List<Feed>>> {
override fun fromXml(konsumer: Konsumer): Map<Folder?, List<Feed>> = try {
var opml: Map<Folder?, List<Feed>>? = null
konsumer.child("opml") {
val version = attributes.getValueOrNull("version")
if (version != "2.0")
throw ParseException("Only 2.0 OPML is supported")
allChildrenAutoIgnore(Names.of("body")) {
opml = parseOutline(this)
}
}
opml!!
} catch (e: Exception) {
throw ParseException(e.message)
}
/**
* Parse outline and its children recursively
* @param konsumer
*/
private fun parseOutline(konsumer: Konsumer): MutableMap<Folder?, MutableList<Feed>> = with(konsumer) {
val opml = mutableMapOf<Folder?, MutableList<Feed>>()
children(Names.of("outline")) {
val title = attributes.getValueOrNull("title")
?: attributes.getValueOrNull("text")
val xmlUrl = attributes.getValueOrNull("xmlUrl")
val htmlUrl = attributes.getValueOrNull("htmlUrl")
val recursiveOpml = parseOutline(this)
// The outline is a folder/category
if (recursiveOpml.containsKey(null) || xmlUrl.isNullOrEmpty()) {
// if the outline doesn't have text or title value but contains sub outlines,
// those sub outlines will be considered as not belonging to any folder and join the others at the top level of the hierarchy
val folder = if (title != null) Folder(name = title) else null
val feeds = recursiveOpml[null] ?: mutableListOf()
opml += mapOf(folder to feeds)
recursiveOpml.remove(null)
opml += recursiveOpml
} else { // the outline is a feed
val feed = Feed().apply {
name = title
url = xmlUrl
siteUrl = htmlUrl
}
// parsed feed is linked to null to be assigned to the previous level folder
if (opml.containsKey(null)) {
opml[null]?.plusAssign(feed)
} else
opml += mapOf(null to mutableListOf(feed))
}
}
return opml
}
}

View File

@ -1,32 +1,24 @@
package com.readrops.api.opml
import com.readrops.api.opml.model.Body
import com.readrops.api.opml.model.Head
import com.readrops.api.opml.model.OPML
import com.readrops.api.opml.model.Outline
import com.readrops.api.utils.exceptions.ParseException
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import io.reactivex.Completable
import io.reactivex.Single
import org.simpleframework.xml.Serializer
import org.simpleframework.xml.core.Persister
import org.redundent.kotlin.xml.xml
import java.io.InputStream
import java.io.OutputStream
object OPMLParser {
val TAG: String = OPMLParser.javaClass.simpleName
@JvmStatic
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
return Single.create { emitter ->
try {
val serializer: Serializer = Persister()
val adapter = OPMLAdapter()
val opml = adapter.fromXml(stream.konsumeXml())
val opml: OPML = serializer.read(OPML::class.java, stream)
emitter.onSuccess(opmlToFoldersAndFeeds(opml))
emitter.onSuccess(opml)
} catch (e: Exception) {
emitter.onError(e)
}
@ -36,115 +28,47 @@ object OPMLParser {
@JvmStatic
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
return Completable.create { emitter ->
val serializer: Serializer = Persister()
serializer.write(foldersAndFeedsToOPML(foldersAndFeeds), outputStream)
val opml = xml("opml") {
attribute("version", "2.0")
"head" {
-"Subscriptions"
}
"body" {
for (folderAndFeeds in foldersAndFeeds) {
if (folderAndFeeds.key != null) { // feeds with folder
"outline" {
folderAndFeeds.key?.name?.let {
attribute("title", it)
attribute("text", it)
}
for (feed in folderAndFeeds.value) {
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
feed.siteUrl?.let { attribute("htmlUrl", it) }
}
}
}
} else {
for (feed in folderAndFeeds.value) { // feeds without folder
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
feed.siteUrl?.let { attribute("htmlUrl", it) }
}
}
}
}
}
}
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
emitter.onComplete()
}
}
private fun opmlToFoldersAndFeeds(opml: OPML): Map<Folder?, List<Feed>> {
if (opml.version != "2.0")
throw ParseException("Only 2.0 OPML specification is supported")
val foldersAndFeeds: MutableMap<Folder?, List<Feed>> = HashMap()
val body = opml.body!!
body.outlines?.forEach { outline ->
val outlineParsing = parseOutline(outline)
associateOrphanFeedsToFolder(foldersAndFeeds, outlineParsing, null)
foldersAndFeeds.putAll(outlineParsing)
}
return foldersAndFeeds
}
/**
* Parse outline and its children recursively
* @param outline node to parse
*/
private fun parseOutline(outline: Outline): MutableMap<Folder?, List<Feed>> {
val foldersAndFeeds: MutableMap<Folder?, List<Feed>> = HashMap()
// The outline is a folder/category
if ((outline.outlines != null && !outline.outlines?.isEmpty()!!) || outline.xmlUrl.isNullOrEmpty()) {
// if the outline doesn't have text or title value but contains sub outlines,
// those sub outlines will be considered as not belonging to any folder and join the others at the top level of the hierarchy
val folder = if (outline.name != null) Folder(name = outline.name) else null
outline.outlines?.forEach {
val recursiveFeedsFolders = parseOutline(it)
// Treat feeds without folder, so belonging to the current folder
associateOrphanFeedsToFolder(foldersAndFeeds, recursiveFeedsFolders, folder)
foldersAndFeeds.putAll(recursiveFeedsFolders.toMap())
}
// empty outline
if (!foldersAndFeeds.containsKey(folder)) foldersAndFeeds[folder] = listOf()
} else { // the outline is a feed
if (!outline.xmlUrl.isNullOrEmpty()) {
val feed = Feed().apply {
name = outline.name
url = outline.xmlUrl
siteUrl = outline.htmlUrl
}
// parsed feed is linked to null to be assigned to the previous level folder
foldersAndFeeds[null] = listOf(feed)
}
}
return foldersAndFeeds
}
private fun foldersAndFeedsToOPML(foldersAndFeeds: Map<Folder?, List<Feed>>): OPML {
val outlines = arrayListOf<Outline>()
for (folderAndFeeds in foldersAndFeeds) {
if (folderAndFeeds.key != null) {
val outline = Outline(folderAndFeeds.key?.name)
val feedOutlines = arrayListOf<Outline>()
for (feed in folderAndFeeds.value) {
val feedOutline = Outline(feed.name, feed.url!!, feed.siteUrl)
feedOutlines += feedOutline
}
outline.outlines = feedOutlines
outlines += outline
} else {
for (feed in folderAndFeeds.value) {
outlines += Outline(feed.name, feed.url!!, feed.siteUrl)
}
}
}
val head = Head("Subscriptions")
val body = Body(outlines)
return OPML("2.0", head, body)
}
/**
* Associate parsed feeds without folder to the previous level folder.
* @param foldersAndFeeds final result
* @param parsingResult current level parsing
* @param folder the folder feeds will be associated to
*
*/
private fun associateOrphanFeedsToFolder(
foldersAndFeeds: MutableMap<Folder?, List<Feed>>,
parsingResult: MutableMap<Folder?, List<Feed>>, folder: Folder?,
) {
val feeds = parsingResult[null]
if (feeds != null && feeds.isNotEmpty()) {
if (foldersAndFeeds[folder] == null) foldersAndFeeds[folder] = feeds
else foldersAndFeeds[folder] = foldersAndFeeds[folder]?.plus(feeds)!!
parsingResult.remove(null)
}
}
}

View File

@ -1,13 +0,0 @@
package com.readrops.api.opml.model
import org.simpleframework.xml.ElementList
import org.simpleframework.xml.Root
@Root(name = "body", strict = false)
data class Body(@field:ElementList(inline = true, required = true) var outlines: List<Outline>?) {
/**
* empty constructor required by SimpleXMl
*/
constructor() : this(null)
}

View File

@ -1,13 +0,0 @@
package com.readrops.api.opml.model
import org.simpleframework.xml.Element
import org.simpleframework.xml.Root
@Root(name = "head", strict = false)
data class Head(@field:Element(required = false) var title: String?) {
/**
* empty constructor required by SimpleXML
*/
constructor() : this(null)
}

View File

@ -1,19 +0,0 @@
package com.readrops.api.opml.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.Element
import org.simpleframework.xml.Order
import org.simpleframework.xml.Root
@Order(elements = ["head", "body"])
@Root(name = "opml", strict = false)
data class OPML(@field:Attribute(required = true) var version: String?,
@field:Element(required = false) var head: Head?,
@field:Element(required = true) var body: Body?) {
/**
* empty constructor required by SimpleXML
*/
constructor() : this(null, null, null)
}

View File

@ -1,32 +0,0 @@
package com.readrops.api.opml.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.ElementList
import org.simpleframework.xml.Root
@Root(name = "outline", strict = false)
data class Outline(@field:Attribute(required = false) private var title: String?,
@field:Attribute(required = false) private var text: String?,
@field:Attribute(required = false) var type: String?,
@field:Attribute(required = false) var xmlUrl: String?,
@field:Attribute(required = false) var htmlUrl: String?,
@field:ElementList(inline = true, required = false) var outlines: List<Outline>?) {
/**
* empty constructor required by SimpleXML
*/
constructor() : this(
null,
null,
null,
null,
null,
null)
constructor(title: String?) : this(title, title, null, null, null, null)
constructor(title: String?, xmlUrl: String, htmlUrl: String?) : this(title, title, "rss", xmlUrl, htmlUrl, null)
val name: String?
get() = title ?: text
}