Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a7f3733
Added OPDS feed models and added code to create opdsfeed.json
Mandvii Dec 24, 2025
cd4f9cf
remove media type constants from opds models
Mandvii Dec 24, 2025
440b08a
changes
Mandvii Dec 30, 2025
a63843f
changes
Mandvii Dec 30, 2025
f2a062b
changes
Mandvii Dec 30, 2025
dabb786
add OPDS web publication models and update feed to use them
Mandvii Dec 30, 2025
b07667b
add OPDS web publication models and update feed to use them
Mandvii Dec 31, 2025
60d017c
added relative link and also the resources
Mandvii Jan 2, 2026
2421901
added relative link and also the resources
Mandvii Jan 2, 2026
34bf955
added relative link and also the resources
Mandvii Jan 2, 2026
ac82c8d
added relative link and also the resources
Mandvii Jan 2, 2026
61b4ff3
remove zip file
Mandvii Jan 2, 2026
c96d405
remove zip file
Mandvii Jan 2, 2026
faed8ca
remove zip file
Mandvii Jan 2, 2026
16a1067
added relative link and also the resources
Mandvii Jan 5, 2026
fd47506
added relative link and also the resources
Mandvii Jan 5, 2026
a8044ef
added relative link and also the resources
Mandvii Jan 5, 2026
63a073e
added relative link and also the resources
Mandvii Jan 6, 2026
99b4b9f
added relative link and also the resources
Mandvii Jan 6, 2026
98a9057
Generate OPDS feed from Kolibri topics
Mandvii Jan 13, 2026
50dd9cb
change
Mandvii Jan 14, 2026
416b29f
feat: Refactor OPDS feed and publication generation
Mandvii Jan 15, 2026
6382651
feat: Refactor OPDS feed and publication generation
Mandvii Jan 15, 2026
ca1f2ff
Refactor: rename allTopics to topics
Mandvii Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions app/src/main/kotlin/com/ustadmobile/zim2xapi/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Topic> = emptyList()

val createdZimFile: File = zimFile ?: if (channelId != null && topicId != null) {

Expand All @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down
133 changes: 131 additions & 2 deletions app/src/main/kotlin/com/ustadmobile/zim2xapi/CreateXapiFileUseCase.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -22,7 +30,8 @@ class CreateXapiFileUseCase(
outputFolder: File,
fileName: String,
zimFile: File,
passingGrade: Int
passingGrade: Int,
topics: List<Topic>
): File {

val indexHtml = File(zimFolder, INDEX_HTML)
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand All @@ -87,11 +190,37 @@ class CreateXapiFileUseCase(
return xapiFile
}

private fun generateResourceLinks(
zimFolder: File,
topic: String
): List<OpdsWebPublicationLink> {
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"

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ class ListKolibriTopicsUseCase(
displayTopicInfo(topic)
}

operator fun invoke(
channelId: String,
topicId: String,
endpoints: List<String>
): List<Topic> {
val rootTopic = fetchTopic(topicId, endpoints)

val topics = mutableListOf<Topic>()

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})")
Expand All @@ -27,7 +43,12 @@ class ListKolibriTopicsUseCase(
displayTopicInfo(child, depth + 1)
}
}

private fun flattenTopics(topic: Topic, list: MutableList<Topic>) {
list.add(topic)
topic.children?.results?.forEach { child ->
flattenTopics(child, list)
}
}
private fun fetchTopic(id: String, endpoints: List<String>): Topic {
// expecting one url to throw a 404 or an error
endpoints.forEach {base ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ustadmobile.zim2xapi.models.opdsfeed

import kotlinx.serialization.Serializable

@Serializable
data class OpdsFeed(
val metadata: OpdsFeedMetadata,
val links: List<ReadiumLink>,
val publications: List<OpdsWebPublication>? = null,
val navigation: List<ReadiumLink>? = null,
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ustadmobile.zim2xapi.models.opdsfeed

import kotlinx.serialization.Serializable

@Serializable
data class OpdsPublication(
val metadata: ReadiumMetadata,
val links: List<ReadiumLink>,
val images: List<ReadiumLink>? = null,
)

Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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<OpdsWebPublicationLink>,
val readingOrder: List<OpdsWebPublicationLink> = emptyList(),
val resources: List<OpdsWebPublicationLink> = emptyList()
)

Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Loading