diff --git a/api/build.gradle b/api/build.gradle index a171643d..28a4906a 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -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' diff --git a/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt b/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt new file mode 100644 index 00000000..5d586b84 --- /dev/null +++ b/api/src/main/java/com/readrops/api/opml/OPMLAdapter.kt @@ -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>> { + + override fun fromXml(konsumer: Konsumer): Map> = try { + var opml: Map>? = 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> = with(konsumer) { + val opml = mutableMapOf>() + + 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 + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt index 6a0c8c93..d7d3fe0e 100644 --- a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt +++ b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt @@ -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>> { 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>, 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> { - if (opml.version != "2.0") - throw ParseException("Only 2.0 OPML specification is supported") - - val foldersAndFeeds: MutableMap> = 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> { - val foldersAndFeeds: MutableMap> = 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>): OPML { - val outlines = arrayListOf() - - for (folderAndFeeds in foldersAndFeeds) { - if (folderAndFeeds.key != null) { - val outline = Outline(folderAndFeeds.key?.name) - - val feedOutlines = arrayListOf() - 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>, - parsingResult: MutableMap>, 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) - } - } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/opml/model/Body.kt b/api/src/main/java/com/readrops/api/opml/model/Body.kt deleted file mode 100644 index 43cf6e96..00000000 --- a/api/src/main/java/com/readrops/api/opml/model/Body.kt +++ /dev/null @@ -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?) { - - /** - * empty constructor required by SimpleXMl - */ - constructor() : this(null) -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/opml/model/Head.kt b/api/src/main/java/com/readrops/api/opml/model/Head.kt deleted file mode 100644 index 6d02b8b4..00000000 --- a/api/src/main/java/com/readrops/api/opml/model/Head.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/opml/model/OPML.kt b/api/src/main/java/com/readrops/api/opml/model/OPML.kt deleted file mode 100644 index 6cdc7087..00000000 --- a/api/src/main/java/com/readrops/api/opml/model/OPML.kt +++ /dev/null @@ -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) - -} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/opml/model/Outline.kt b/api/src/main/java/com/readrops/api/opml/model/Outline.kt deleted file mode 100644 index 2b0635ef..00000000 --- a/api/src/main/java/com/readrops/api/opml/model/Outline.kt +++ /dev/null @@ -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?) { - - /** - * 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 -} \ No newline at end of file