mirror of
https://github.com/InsanusMokrassar/TelegramBotApiLibraries.git
synced 2024-12-22 16:47:16 +00:00
implement content caching
This commit is contained in:
parent
6591c8ffa8
commit
f423d31423
@ -6,3 +6,13 @@ plugins {
|
|||||||
|
|
||||||
apply from: "$mppProjectWithSerializationPresetPath"
|
apply from: "$mppProjectWithSerializationPresetPath"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain {
|
||||||
|
dependencies {
|
||||||
|
api "dev.inmo:tgbotapi.core:$tgbotapi_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
|||||||
|
package dev.inmo.tgbotapi.libraries.cache.media.common
|
||||||
|
|
||||||
|
import dev.inmo.tgbotapi.bot.TelegramBot
|
||||||
|
import dev.inmo.tgbotapi.requests.DownloadFileStream
|
||||||
|
import dev.inmo.tgbotapi.requests.abstracts.MultipartFile
|
||||||
|
import dev.inmo.tgbotapi.requests.get.GetFile
|
||||||
|
import dev.inmo.tgbotapi.requests.send.media.*
|
||||||
|
import dev.inmo.tgbotapi.types.ChatId
|
||||||
|
import dev.inmo.tgbotapi.types.InputMedia.*
|
||||||
|
import dev.inmo.tgbotapi.types.MessageIdentifier
|
||||||
|
import dev.inmo.tgbotapi.types.message.content.abstracts.MediaContent
|
||||||
|
import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent
|
||||||
|
import io.ktor.utils.io.cancel
|
||||||
|
|
||||||
|
class DefaultMessageContentCache<T>(
|
||||||
|
private val bot: TelegramBot,
|
||||||
|
private val simpleMessageContentCache: MessageContentCache,
|
||||||
|
private val messagesFilesCache: MessagesFilesCache,
|
||||||
|
private val filesRefreshingChatId: ChatId
|
||||||
|
) : MessageContentCache {
|
||||||
|
override suspend fun save(chatId: ChatId, messageId: MessageIdentifier, content: MessageContent): Boolean {
|
||||||
|
runCatching {
|
||||||
|
if (content is MediaContent) {
|
||||||
|
val extendedInfo = bot.execute(
|
||||||
|
GetFile(content.media.fileId)
|
||||||
|
)
|
||||||
|
val allocator = bot.execute(
|
||||||
|
DownloadFileStream(
|
||||||
|
extendedInfo.filePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messagesFilesCache.set(chatId, messageId, extendedInfo.fileName, allocator)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return simpleMessageContentCache.save(
|
||||||
|
chatId, messageId, content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(chatId: ChatId, messageId: MessageIdentifier): MessageContent? {
|
||||||
|
val savedSimpleContent = simpleMessageContentCache.get(chatId, messageId) ?: return null
|
||||||
|
|
||||||
|
if (savedSimpleContent is MediaContent) {
|
||||||
|
runCatching {
|
||||||
|
val streamAllocator = bot.execute(
|
||||||
|
DownloadFileStream(
|
||||||
|
bot.execute(
|
||||||
|
GetFile(
|
||||||
|
savedSimpleContent.media.fileId
|
||||||
|
)
|
||||||
|
).filePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
streamAllocator().apply {
|
||||||
|
readByte()
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
val savedFileContentAllocator = messagesFilesCache.get(chatId, messageId) ?: error("Unexpected absence of $chatId:$messageId file for content ($simpleMessageContentCache)")
|
||||||
|
val newContent = bot.execute(
|
||||||
|
when (savedSimpleContent.asInputMedia()) {
|
||||||
|
is InputMediaAnimation -> SendAnimation(
|
||||||
|
filesRefreshingChatId,
|
||||||
|
MultipartFile(
|
||||||
|
savedFileContentAllocator
|
||||||
|
),
|
||||||
|
disableNotification = true
|
||||||
|
)
|
||||||
|
is InputMediaAudio -> SendAudio(
|
||||||
|
filesRefreshingChatId,
|
||||||
|
MultipartFile(
|
||||||
|
savedFileContentAllocator
|
||||||
|
),
|
||||||
|
disableNotification = true
|
||||||
|
)
|
||||||
|
is InputMediaVideo -> SendVideo(
|
||||||
|
filesRefreshingChatId,
|
||||||
|
MultipartFile(
|
||||||
|
savedFileContentAllocator
|
||||||
|
),
|
||||||
|
disableNotification = true
|
||||||
|
)
|
||||||
|
is InputMediaDocument -> SendDocument(
|
||||||
|
filesRefreshingChatId,
|
||||||
|
MultipartFile(
|
||||||
|
savedFileContentAllocator
|
||||||
|
),
|
||||||
|
disableNotification = true
|
||||||
|
)
|
||||||
|
is InputMediaPhoto -> SendPhoto(
|
||||||
|
filesRefreshingChatId,
|
||||||
|
MultipartFile(
|
||||||
|
savedFileContentAllocator
|
||||||
|
),
|
||||||
|
disableNotification = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
simpleMessageContentCache.save(chatId, messageId, newContent.content)
|
||||||
|
return newContent.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return savedSimpleContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun contains(chatId: ChatId, messageId: MessageIdentifier): Boolean {
|
||||||
|
return simpleMessageContentCache.contains(chatId, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(chatId: ChatId, messageId: MessageIdentifier) {
|
||||||
|
simpleMessageContentCache.remove(chatId, messageId)
|
||||||
|
messagesFilesCache.remove(chatId, messageId)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package dev.inmo.tgbotapi.libraries.cache.media.common
|
||||||
|
|
||||||
|
import dev.inmo.tgbotapi.types.ChatId
|
||||||
|
import dev.inmo.tgbotapi.types.MessageIdentifier
|
||||||
|
import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent
|
||||||
|
|
||||||
|
interface MessageContentCache {
|
||||||
|
suspend fun save(chatId: ChatId, messageId: MessageIdentifier, content: MessageContent): Boolean
|
||||||
|
suspend fun get(chatId: ChatId, messageId: MessageIdentifier): MessageContent?
|
||||||
|
suspend fun contains(chatId: ChatId, messageId: MessageIdentifier): Boolean
|
||||||
|
suspend fun remove(chatId: ChatId, messageId: MessageIdentifier)
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
package dev.inmo.tgbotapi.libraries.cache.media.common
|
||||||
|
|
||||||
|
import dev.inmo.tgbotapi.types.ChatId
|
||||||
|
import dev.inmo.tgbotapi.types.MessageIdentifier
|
||||||
|
import dev.inmo.tgbotapi.utils.ByteReadChannelAllocator
|
||||||
|
import dev.inmo.tgbotapi.utils.StorageFile
|
||||||
|
import io.ktor.util.toByteArray
|
||||||
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
|
|
||||||
|
interface MessagesFilesCache {
|
||||||
|
suspend fun set(chatId: ChatId, messageIdentifier: MessageIdentifier, filename: String, byteReadChannelAllocator: ByteReadChannelAllocator)
|
||||||
|
suspend fun get(chatId: ChatId, messageIdentifier: MessageIdentifier): StorageFile?
|
||||||
|
suspend fun remove(chatId: ChatId, messageIdentifier: MessageIdentifier)
|
||||||
|
suspend fun contains(chatId: ChatId, messageIdentifier: MessageIdentifier): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It is not recommended to use in production realization of [MessagesFilesCache] which has been created for fast
|
||||||
|
* start of application creation with usage of [MessageContentCache] with aim to replace this realization by some
|
||||||
|
* disks-oriented one
|
||||||
|
*/
|
||||||
|
class InMemoryMessagesFilesCache : MessagesFilesCache {
|
||||||
|
private val map = mutableMapOf<Pair<ChatId, MessageIdentifier>, StorageFile>()
|
||||||
|
|
||||||
|
override suspend fun set(
|
||||||
|
chatId: ChatId,
|
||||||
|
messageIdentifier: MessageIdentifier,
|
||||||
|
filename: String,
|
||||||
|
byteReadChannelAllocator: ByteReadChannelAllocator
|
||||||
|
) {
|
||||||
|
map[chatId to messageIdentifier] = StorageFile(
|
||||||
|
filename,
|
||||||
|
byteReadChannelAllocator.invoke().toByteArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(chatId: ChatId, messageIdentifier: MessageIdentifier): StorageFile? {
|
||||||
|
return map[chatId to messageIdentifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(chatId: ChatId, messageIdentifier: MessageIdentifier) {
|
||||||
|
map.remove(chatId to messageIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun contains(chatId: ChatId, messageIdentifier: MessageIdentifier): Boolean {
|
||||||
|
return map.contains(chatId to messageIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
package dev.inmo.tgbotapi.libraries.cache.media.common
|
||||||
|
|
||||||
|
import dev.inmo.tgbotapi.types.ChatId
|
||||||
|
import dev.inmo.tgbotapi.types.MessageIdentifier
|
||||||
|
import dev.inmo.tgbotapi.utils.*
|
||||||
|
import io.ktor.utils.io.core.copyTo
|
||||||
|
import io.ktor.utils.io.streams.asInput
|
||||||
|
import io.ktor.utils.io.streams.asOutput
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class InFilesMessagesFilesCache(
|
||||||
|
private val folderFile: File
|
||||||
|
) : MessagesFilesCache {
|
||||||
|
private val Pair<ChatId, MessageIdentifier>.storageFile: StorageFile?
|
||||||
|
get() {
|
||||||
|
val prefix = filePrefix(first, second)
|
||||||
|
val filename = folderFile.list() ?.firstOrNull { it.startsWith(prefix) } ?: return null
|
||||||
|
val file = File(folderFile, filename)
|
||||||
|
val storageFileFilename = file.name.removePrefix("$prefix ")
|
||||||
|
|
||||||
|
return StorageFile(
|
||||||
|
StorageFileInfo(storageFileFilename)
|
||||||
|
) {
|
||||||
|
file.inputStream().asInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(!folderFile.isFile) { "Folder of messages files cache can't be file, but was $folderFile" }
|
||||||
|
folderFile.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filePrefix(chatId: ChatId, messageIdentifier: MessageIdentifier): String {
|
||||||
|
return "${chatId.chatId} $messageIdentifier"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileName(chatId: ChatId, messageIdentifier: MessageIdentifier, filename: String): String {
|
||||||
|
return "${chatId.chatId} $messageIdentifier $filename"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun set(
|
||||||
|
chatId: ChatId,
|
||||||
|
messageIdentifier: MessageIdentifier,
|
||||||
|
filename: String,
|
||||||
|
byteReadChannelAllocator: ByteReadChannelAllocator
|
||||||
|
) {
|
||||||
|
val fullFileName = fileName(chatId, messageIdentifier, filename)
|
||||||
|
val file = File(folderFile, fullFileName).apply {
|
||||||
|
delete()
|
||||||
|
}
|
||||||
|
byteReadChannelAllocator.invoke().asInput().use { input ->
|
||||||
|
file.outputStream().asOutput().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(chatId: ChatId, messageIdentifier: MessageIdentifier): StorageFile? {
|
||||||
|
return (chatId to messageIdentifier).storageFile
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(chatId: ChatId, messageIdentifier: MessageIdentifier) {
|
||||||
|
val prefix = filePrefix(chatId, messageIdentifier)
|
||||||
|
folderFile.listFiles() ?.forEach {
|
||||||
|
if (it.name.startsWith(prefix)) {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun contains(chatId: ChatId, messageIdentifier: MessageIdentifier): Boolean {
|
||||||
|
val prefix = filePrefix(chatId, messageIdentifier)
|
||||||
|
return folderFile.list() ?.any { it.startsWith(prefix) } == true
|
||||||
|
}
|
||||||
|
}
|
1
cache/content/common/src/main/AndroidManifest.xml
vendored
Normal file
1
cache/content/common/src/main/AndroidManifest.xml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<manifest package="dev.inmo.tgbotapi.libraries.cache.content.common"/>
|
19
cache/content/micro_utils/build.gradle
vendored
Normal file
19
cache/content/micro_utils/build.gradle
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
plugins {
|
||||||
|
id "org.jetbrains.kotlin.multiplatform"
|
||||||
|
id "org.jetbrains.kotlin.plugin.serialization"
|
||||||
|
id "com.android.library"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$mppProjectWithSerializationPresetPath"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain {
|
||||||
|
dependencies {
|
||||||
|
api "dev.inmo:micro_utils.repos.common:$micro_utils_version"
|
||||||
|
api project(":tgbotapi.libraries.cache.content.common")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
|||||||
|
package dev.inmo.tgbotapi.libraries.cache.media.micro_utils
|
||||||
|
|
||||||
|
import dev.inmo.micro_utils.repos.*
|
||||||
|
import dev.inmo.micro_utils.repos.mappers.withMapper
|
||||||
|
import dev.inmo.tgbotapi.libraries.cache.media.common.MessageContentCache
|
||||||
|
import dev.inmo.tgbotapi.types.ChatId
|
||||||
|
import dev.inmo.tgbotapi.types.MessageIdentifier
|
||||||
|
import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent
|
||||||
|
import kotlinx.serialization.*
|
||||||
|
import kotlinx.serialization.builtins.PairSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlin.js.JsName
|
||||||
|
import kotlin.jvm.JvmName
|
||||||
|
|
||||||
|
class SimpleKeyValueMessageContentCache(
|
||||||
|
private val keyValueRepo: KeyValueRepo<Pair<ChatId, MessageIdentifier>, MessageContent>
|
||||||
|
) : MessageContentCache {
|
||||||
|
override suspend fun save(chatId: ChatId, messageId: MessageIdentifier, content: MessageContent): Boolean {
|
||||||
|
return keyValueRepo.runCatching {
|
||||||
|
set(chatId to messageId, content)
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(chatId: ChatId, messageId: MessageIdentifier): MessageContent? {
|
||||||
|
return keyValueRepo.get(chatId to messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun contains(chatId: ChatId, messageId: MessageIdentifier): Boolean {
|
||||||
|
return keyValueRepo.contains(chatId to messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(chatId: ChatId, messageId: MessageIdentifier) {
|
||||||
|
keyValueRepo.unset(chatId to messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatIdToMessageIdentifierSerializer = PairSerializer(
|
||||||
|
ChatId.serializer(),
|
||||||
|
MessageIdentifier.serializer()
|
||||||
|
)
|
||||||
|
|
||||||
|
val messageContentSerializer = PolymorphicSerializer<MessageContent>(MessageContent::class)
|
||||||
|
|
||||||
|
inline fun KeyValueRepo<String, String>.asMessageContentCache(
|
||||||
|
serialFormatCreator: (SerializersModule) -> StringFormat = { Json { serializersModule = it } }
|
||||||
|
): StandardKeyValueRepo<Pair<ChatId, MessageIdentifier>, MessageContent> {
|
||||||
|
val serialFormat = serialFormatCreator(MessageContent.serializationModule())
|
||||||
|
return withMapper<Pair<ChatId, MessageIdentifier>, MessageContent, String, String>(
|
||||||
|
{ serialFormat.encodeToString(chatIdToMessageIdentifierSerializer, this) },
|
||||||
|
{ serialFormat.encodeToString(messageContentSerializer, this) },
|
||||||
|
{ serialFormat.decodeFromString(chatIdToMessageIdentifierSerializer, this) },
|
||||||
|
{ serialFormat.decodeFromString(messageContentSerializer, this) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("stringsKeyValueAsHexMessageContentCache")
|
||||||
|
@JsName("stringsKeyValueAsHexMessageContentCache")
|
||||||
|
inline fun KeyValueRepo<String, String>.asMessageContentCache(
|
||||||
|
serialFormatCreator: (SerializersModule) -> BinaryFormat
|
||||||
|
): StandardKeyValueRepo<Pair<ChatId, MessageIdentifier>, MessageContent> {
|
||||||
|
val serialFormat = serialFormatCreator(MessageContent.serializationModule())
|
||||||
|
return withMapper<Pair<ChatId, MessageIdentifier>, MessageContent, String, String>(
|
||||||
|
{ serialFormat.encodeToHexString(chatIdToMessageIdentifierSerializer, this) },
|
||||||
|
{ serialFormat.encodeToHexString(messageContentSerializer, this) },
|
||||||
|
{ serialFormat.decodeFromHexString(chatIdToMessageIdentifierSerializer, this) },
|
||||||
|
{ serialFormat.decodeFromHexString(messageContentSerializer, this) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("bytesKeyValueAsMessageContentCache")
|
||||||
|
@JsName("bytesKeyValueAsMessageContentCache")
|
||||||
|
inline fun KeyValueRepo<ByteArray, ByteArray>.asMessageContentCache(
|
||||||
|
serialFormatCreator: (SerializersModule) -> BinaryFormat
|
||||||
|
): StandardKeyValueRepo<Pair<ChatId, MessageIdentifier>, MessageContent> {
|
||||||
|
val serialFormat = serialFormatCreator(MessageContent.serializationModule())
|
||||||
|
return withMapper<Pair<ChatId, MessageIdentifier>, MessageContent, ByteArray, ByteArray>(
|
||||||
|
{ serialFormat.encodeToByteArray(chatIdToMessageIdentifierSerializer, this) },
|
||||||
|
{ serialFormat.encodeToByteArray(messageContentSerializer, this) },
|
||||||
|
{ serialFormat.decodeFromByteArray(chatIdToMessageIdentifierSerializer, this) },
|
||||||
|
{ serialFormat.decodeFromByteArray(messageContentSerializer, this) },
|
||||||
|
)
|
||||||
|
}
|
1
cache/content/micro_utils/src/main/AndroidManifest.xml
vendored
Normal file
1
cache/content/micro_utils/src/main/AndroidManifest.xml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<manifest package="dev.inmo.tgbotapi.libraries.cache.content.micro_utils"/>
|
1
cache/media/src/main/AndroidManifest.xml
vendored
1
cache/media/src/main/AndroidManifest.xml
vendored
@ -1 +0,0 @@
|
|||||||
<manifest package="dev.inmo.tgbotapi.libraries.cache.media"/>
|
|
@ -11,9 +11,9 @@ kotlin_serialisation_core_version=1.3.2
|
|||||||
|
|
||||||
github_release_plugin_version=2.2.12
|
github_release_plugin_version=2.2.12
|
||||||
|
|
||||||
tgbotapi_version=0.38.4
|
tgbotapi_version=0.38.9
|
||||||
micro_utils_version=0.9.5
|
micro_utils_version=0.9.16
|
||||||
exposed_version=0.37.2
|
exposed_version=0.37.3
|
||||||
plagubot_version=0.5.1
|
plagubot_version=0.5.1
|
||||||
|
|
||||||
# ANDROID
|
# ANDROID
|
||||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -4,13 +4,15 @@ String[] includes = [
|
|||||||
":cache:admins:common",
|
":cache:admins:common",
|
||||||
":cache:admins:micro_utils",
|
":cache:admins:micro_utils",
|
||||||
":cache:admins:plagubot",
|
":cache:admins:plagubot",
|
||||||
":cache:media"
|
|
||||||
|
":cache:content:common",
|
||||||
|
":cache:content:micro_utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
includes.each { originalName ->
|
includes.each { originalName ->
|
||||||
String projectDirectory = "${rootProject.projectDir.getAbsolutePath()}${originalName.replaceAll(":", File.separator)}"
|
String projectDirectory = "${rootProject.projectDir.getAbsolutePath()}${originalName.replace(":", File.separator)}"
|
||||||
String projectName = "${rootProject.name}${originalName.replaceAll(":", ".")}"
|
String projectName = "${rootProject.name}${originalName.replace(":", ".")}"
|
||||||
String projectIdentifier = ":${projectName}"
|
String projectIdentifier = ":${projectName}"
|
||||||
include projectIdentifier
|
include projectIdentifier
|
||||||
ProjectDescriptor project = project(projectIdentifier)
|
ProjectDescriptor project = project(projectIdentifier)
|
||||||
|
Loading…
Reference in New Issue
Block a user