diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/App.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/App.kt index aca431a..11cbb63 100644 --- a/app/src/main/kotlin/com/ustadmobile/zim2xapi/App.kt +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/App.kt @@ -12,11 +12,11 @@ import com.github.ajalt.clikt.parameters.types.file import com.github.ajalt.clikt.parameters.types.int import com.ustadmobile.zim2xapi.Client.client import com.ustadmobile.zim2xapi.Client.json +import com.ustadmobile.zim2xapi.models.Topic import com.ustadmobile.zim2xapi.utils.SysPathUtil import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import java.io.File -import java.io.FileNotFoundException object Client { // Create a single OkHttpClient instance @@ -111,11 +111,21 @@ class DownloadTopic : CliktCommand(name = "convert") { val keepTempFiles by option("-k","-keep-temp", help = "Keep temporary files").flag() + val endpoints by option("--endpoint", "-e") + .convert { it.split(",") } + .default( + listOf( + "https://kolibri-demo.learningequality.org", + "https://kolibri-catalog-en.learningequality.org" + ) + ) + override fun run() { val channelId = channelId val topicId = topicId val zimFile = zimFile + var topics: List = emptyList() val createdZimFile: File = zimFile ?: if (channelId != null && topicId != null) { @@ -130,6 +140,8 @@ class DownloadTopic : CliktCommand(name = "convert") { val kolbir2zimProcess = ProcessBuilderUseCase(kolibri2zimPath) + topics = ListKolibriTopicsUseCase(client, json).invoke(channelId, topicId, endpoints) + DownloadKolibriZimUseCase(kolbir2zimProcess).invoke( channelId, topicId, @@ -163,13 +175,15 @@ class DownloadTopic : CliktCommand(name = "convert") { // fix any exceptions found in the folder FixExtractZimExceptionsUseCase(zimDumpProcess).invoke(createdZimFile, extractedZimFolder) + // create the xApi zip file val xapiFile = CreateXapiFileUseCase(zimDumpProcess, AddxAPIStatementUseCase(), json).invoke( extractedZimFolder, outputDir, fileName, createdZimFile, - passingGrade + passingGrade, + topics ) echo("Process completed. Output filename: ${xapiFile.name}") diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCase.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCase.kt index 490b218..3590fc9 100644 --- a/app/src/main/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCase.kt +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCase.kt @@ -1,7 +1,14 @@ package com.ustadmobile.zim2xapi import com.ustadmobile.zim2xapi.models.ActivityDefinition +import com.ustadmobile.zim2xapi.models.Topic import com.ustadmobile.zim2xapi.models.XapiObject +import com.ustadmobile.zim2xapi.models.opdsfeed.OpdsFeed +import com.ustadmobile.zim2xapi.models.opdsfeed.OpdsFeedMetadata +import com.ustadmobile.zim2xapi.models.opdsfeed.OpdsWebMetadata +import com.ustadmobile.zim2xapi.models.opdsfeed.ReadiumLink +import com.ustadmobile.zim2xapi.models.opdsfeed.OpdsWebPublication +import com.ustadmobile.zim2xapi.models.opdsfeed.OpdsWebPublicationLink import kotlinx.serialization.json.Json import org.jsoup.Jsoup import java.io.File @@ -10,6 +17,7 @@ import java.io.FileOutputStream import java.io.PrintWriter import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream +import kotlin.reflect.typeOf class CreateXapiFileUseCase( private val zimDumpProcess: ProcessBuilderUseCase, @@ -22,7 +30,8 @@ class CreateXapiFileUseCase( outputFolder: File, fileName: String, zimFile: File, - passingGrade: Int + passingGrade: Int, + topics: List ): File { val indexHtml = File(zimFolder, INDEX_HTML) @@ -54,7 +63,7 @@ class CreateXapiFileUseCase( ) } - val xapiObjectJsonFile = File(zimFolder, "xapiobject.json") + val xapiObjectJsonFile = File(zimFolder, XAPI_OBJECT_JSON) xapiObjectJsonFile.writeText( json.encodeToString( XapiObject.serializer(), XapiObject( @@ -68,6 +77,100 @@ class CreateXapiFileUseCase( ) ) + val path = zimFolder.absolutePath.toString() + val topicId = path.split("/").last() + val assetResources = generateResourceLinks(zimFolder, topicId) + + val opdsFeedJsonFile = File(zimFolder, OPDS_JSON) + opdsFeedJsonFile.writeText( + json.encodeToString( + OpdsFeed.serializer(), OpdsFeed( + metadata = OpdsFeedMetadata( + title = title, + description = description + ), + links = listOf( + ReadiumLink( + href = "$topicId/$topicId", + title = title, + ) + ), + navigation = listOf( + ReadiumLink( + href = "$topicId/$topicId.json", + title = title + ), + ) + ) + ) + ) + + val publicationLinks = topics.map { subTopic -> + OpdsWebPublication( + links = listOf( + OpdsWebPublicationLink( + rel = SELF_LINK, + href = "$topicId/${subTopic.id}.json" + ), + ), + context = "", + metadata = OpdsWebMetadata( + title = subTopic.title, + description = subTopic.description, + identifier = subTopic.id + ), + ) + } + + val topicJsonFile = File(zimFolder, "${topicId}.json") + topicJsonFile.writeText( + json.encodeToString( + OpdsFeed.serializer(), OpdsFeed( + metadata = OpdsFeedMetadata( + title = title, + description = description + ), + links = listOf( + ReadiumLink( + href = "$topicId/$topicId.json", + title = title, + ) + ), + publications = publicationLinks + ) + ) + ) + + topics.forEach { subTopic -> + val subTopicJsonFile = File(zimFolder, "${subTopic.id}.json") + subTopicJsonFile.writeText( + json.encodeToString( + OpdsWebPublication.serializer(), + OpdsWebPublication( + context = "", + metadata = OpdsWebMetadata( + title = subTopic.title, + description = subTopic.description, + identifier = subTopic.id + ), + links = listOf( + OpdsWebPublicationLink( + rel = SELF_LINK, + href = "$topicId/${subTopic.id}" + ), + OpdsWebPublicationLink( + rel = ACQUISITION_LINK, + href = "$topicId/$INDEX_HTML", + type = "text/html" + ) + ), + resources = assetResources + ) + ) + ) + } + + addXApi.invoke(zimFolder, passingGrade) val xapiFile = File(outputFolder, "$fileName.zip") @@ -87,11 +190,37 @@ class CreateXapiFileUseCase( return xapiFile } + private fun generateResourceLinks( + zimFolder: File, + topic: String + ): List { + val assetsFolder = File(zimFolder, ASSESTS) + if (!assetsFolder.exists() || !assetsFolder.isDirectory) return emptyList() + + return assetsFolder.walk() + .filter { it.isFile } + .map { file -> + val relativePath = zimFolder.toPath().relativize(file.toPath()).toString() + OpdsWebPublicationLink( + href = "$topic/$relativePath", + ) + } + .toList() + } + companion object { const val TINCAN_XML = "tincan.xml" const val INDEX_HTML = "index.html" + const val XAPI_OBJECT_JSON = "xapiobject.json" + const val OPDS_JSON = "opds.json" + + const val ASSESTS = "assets" + + const val SELF_LINK = "self" + const val ACQUISITION_LINK = "http://opds-spec.org/acquisition/open-access" + const val ACTIVITY_TYPE = "http://adlnet.gov/expapi/activities/module" } diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/ListKolibriTopicsUseCase.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/ListKolibriTopicsUseCase.kt index ba4f194..4d3d833 100644 --- a/app/src/main/kotlin/com/ustadmobile/zim2xapi/ListKolibriTopicsUseCase.kt +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/ListKolibriTopicsUseCase.kt @@ -19,6 +19,22 @@ class ListKolibriTopicsUseCase( displayTopicInfo(topic) } + operator fun invoke( + channelId: String, + topicId: String, + endpoints: List + ): List { + val rootTopic = fetchTopic(topicId, endpoints) + + val topics = mutableListOf() + + rootTopic.children?.results?.forEach { child -> + flattenTopics(child, topics) + } + + return topics + } + private fun displayTopicInfo(topic: Topic, depth: Int = 0) { val indent = " ".repeat(depth) println("$indent- ${topic.title} (ID: ${topic.id}, Kind: ${topic.kind}, Leaf: ${topic.is_leaf})") @@ -27,7 +43,12 @@ class ListKolibriTopicsUseCase( displayTopicInfo(child, depth + 1) } } - + private fun flattenTopics(topic: Topic, list: MutableList) { + list.add(topic) + topic.children?.results?.forEach { child -> + flattenTopics(child, list) + } + } private fun fetchTopic(id: String, endpoints: List): Topic { // expecting one url to throw a 404 or an error endpoints.forEach {base -> diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsFeed.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsFeed.kt new file mode 100644 index 0000000..9c0f08a --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsFeed.kt @@ -0,0 +1,11 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsFeed( + val metadata: OpdsFeedMetadata, + val links: List, + val publications: List? = null, + val navigation: List? = null, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsFeedMetaData.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsFeedMetaData.kt new file mode 100644 index 0000000..d79613f --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsFeedMetaData.kt @@ -0,0 +1,9 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsFeedMetadata( + val title: String, + val description: String? = null +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsPublication.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsPublication.kt new file mode 100644 index 0000000..db0decf --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsPublication.kt @@ -0,0 +1,11 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsPublication( + val metadata: ReadiumMetadata, + val links: List, + val images: List? = null, +) + diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebMetadata.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebMetadata.kt new file mode 100644 index 0000000..736a029 --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebMetadata.kt @@ -0,0 +1,14 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsWebMetadata( + val type: String? = null, + val title: String, + val author: String? = null, + val identifier: String, + val language: String? = null, + val modified: String? = null, + val description: String? = null +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebPublication.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebPublication.kt new file mode 100644 index 0000000..261f73b --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebPublication.kt @@ -0,0 +1,13 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsWebPublication( + val context: String, + val metadata: OpdsWebMetadata, + val links: List, + val readingOrder: List = emptyList(), + val resources: List = emptyList() +) + diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebPublicationLink.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebPublicationLink.kt new file mode 100644 index 0000000..a64e131 --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/OpdsWebPublicationLink.kt @@ -0,0 +1,10 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsWebPublicationLink( + val rel: String? = null, + val href: String, + val type: String? = null +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/ReadiumLink.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/ReadiumLink.kt new file mode 100644 index 0000000..4eb380f --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/ReadiumLink.kt @@ -0,0 +1,9 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class ReadiumLink( + val href: String, + val title: String? = null +) diff --git a/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/ReadiumMetadata.kt b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/ReadiumMetadata.kt new file mode 100644 index 0000000..60cfd1a --- /dev/null +++ b/app/src/main/kotlin/com/ustadmobile/zim2xapi/models/opdsfeed/ReadiumMetadata.kt @@ -0,0 +1,9 @@ +package com.ustadmobile.zim2xapi.models.opdsfeed + +import kotlinx.serialization.Serializable + +@Serializable +data class ReadiumMetadata( + val title: String, + val description: String? = null +) \ No newline at end of file diff --git a/app/src/test/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCaseTest.kt b/app/src/test/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCaseTest.kt index 9752b0f..75b2bda 100644 --- a/app/src/test/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCaseTest.kt +++ b/app/src/test/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCaseTest.kt @@ -28,7 +28,9 @@ class CreateXapiFileUseCaseTest { mockZimDumpProcess("uuid: 123e4567-e89b-12d3-a456-426614174000") val fileName = "outputFile" - createXapiFileUseCase.invoke(zimFolder, outputFolder, fileName, zimFile, 50) + createXapiFileUseCase.invoke( + zimFolder, outputFolder, fileName, zimFile, 50, topics = emptyList() + ) val tinCanFile = File(zimFolder, "tincan.xml") assertTrue(tinCanFile.exists(), "tincan.xml file should be created") @@ -58,7 +60,9 @@ class CreateXapiFileUseCaseTest { // Act & Assert val exception = assertFailsWith { - createXapiFileUseCase.invoke(zimFolder, outputFolder, "outputFile", zimFile, 50) + createXapiFileUseCase.invoke( + zimFolder, outputFolder, "outputFile", zimFile, 50, topics = emptyList(), + ) } assertTrue(exception.message?.contains("index.html") == true, "Expected an exception regarding missing index.html") @@ -85,7 +89,7 @@ class CreateXapiFileUseCaseTest { // Act & Assert val exception = assertFailsWith { - createXapiFileUseCase.invoke(zimFolder, outputFolder, "outputFile", zimFile, 50) + createXapiFileUseCase.invoke(zimFolder, outputFolder, "outputFile", zimFile, 50, topics = emptyList()) } assertTrue(exception.message?.contains("uuid not provided by zimdump") == true)