diff --git a/build.gradle.kts b/build.gradle.kts index 16a73b0..feef594 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,8 @@ repositories { jcenter() } -group = "com.petersamokhin.notionapi" -version = "0.0.6" +group = "com.petersamokhin.notionsdk" +version = "1.0.0" dependencies { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 353657d..0fa51c5 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -1,10 +1,10 @@ object Config { object Versions { object Kotlin { - const val kotlin = "1.4.20-RC" - const val coroutines = "1.4.1" + const val kotlin = "1.4.20" const val serialization = "1.0.1" } - const val ktor = "1.4.1" + + const val ktor = "1.4.2" } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index df0fe1a..f99c0d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "notionapi" \ No newline at end of file +rootProject.name = "notion-sdk-kotlin" \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/Notion.kt b/src/main/kotlin/com/petersamokhin/notionapi/Notion.kt index 085d88e..79a46e8 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/Notion.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/Notion.kt @@ -1,18 +1,21 @@ package com.petersamokhin.notionapi -import com.petersamokhin.notionapi.model.LoadPageChunkRequestBody -import com.petersamokhin.notionapi.model.Loader -import com.petersamokhin.notionapi.model.NotionResponse -import com.petersamokhin.notionapi.model.QueryCollectionRequestBody +import com.petersamokhin.notionapi.model.NotionCredentials +import com.petersamokhin.notionapi.model.error.NotionAuthException +import com.petersamokhin.notionapi.model.request.LoadPageChunkRequestBody +import com.petersamokhin.notionapi.model.request.Loader +import com.petersamokhin.notionapi.model.request.QueryCollectionRequestBody +import com.petersamokhin.notionapi.model.response.NotionResponse import com.petersamokhin.notionapi.request.LoadPageChunkRequest import com.petersamokhin.notionapi.request.QueryNotionCollectionRequest +import com.petersamokhin.notionapi.request.base.NotionRequest import io.ktor.client.* import io.ktor.client.features.* import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.util.* -class Notion(private val token: String, private var httpClient: HttpClient) { +class Notion internal constructor(token: String, private var httpClient: HttpClient) { init { httpClient = httpClient.config { defaultRequest { @@ -21,14 +24,12 @@ class Notion(private val token: String, private var httpClient: HttpClient) { } } - @KtorExperimentalAPI suspend fun loadPage(pageId: String, limit: Int = 50): NotionResponse { return LoadPageChunkRequest(httpClient).execute( LoadPageChunkRequestBody(pageId, limit, 0, false) ) } - @KtorExperimentalAPI suspend fun queryCollection(collectionId: String, collectionViewId: String, limit: Int = 70): NotionResponse { return QueryNotionCollectionRequest(httpClient).execute( QueryCollectionRequestBody( @@ -39,7 +40,34 @@ class Notion(private val token: String, private var httpClient: HttpClient) { fun close() = httpClient.close() + fun setHttpClient(newHttpClient: HttpClient) { + httpClient = newHttpClient + } + companion object { private const val NOTION_TOKEN_COOKIE_KEY = "token_v2" + + @JvmStatic + fun fromToken(token: String, httpClient: HttpClient): Notion { + return Notion(token, httpClient) + } + + @JvmStatic + suspend fun fromEmailAndPassword(credentials: NotionCredentials, httpClient: HttpClient): Notion { + val endpoint = "${NotionRequest.API_BASE_URL}/${NotionRequest.Endpoint.LOGIN_WITH_EMAIL}" + val response = httpClient.post(endpoint) { + headers.appendAll(NotionRequest.BASE_HEADERS) + contentType(ContentType.Application.Json) + body = credentials + } + + val token = response.headers.getAll(HttpHeaders.SetCookie)?.firstOrNull { + it.contains("$NOTION_TOKEN_COOKIE_KEY=", true) + }?.split("; ")?.firstOrNull { + it.contains("$NOTION_TOKEN_COOKIE_KEY=", true) + }?.split("=")?.getOrNull(1) ?: throw NotionAuthException("No $NOTION_TOKEN_COOKIE_KEY in headers!") + + return fromToken(token, httpClient) + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionPropertyMapper.kt b/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionPropertyMapper.kt new file mode 100644 index 0000000..becde00 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionPropertyMapper.kt @@ -0,0 +1,91 @@ +package com.petersamokhin.notionapi.mapper + +import com.petersamokhin.notionapi.model.NotionColumn +import com.petersamokhin.notionapi.model.NotionProperty +import com.petersamokhin.notionapi.model.response.NotionCollection +import com.petersamokhin.notionapi.model.response.NotionColumnType +import com.petersamokhin.notionapi.serializer.NotionBooleanSerializer +import com.petersamokhin.notionapi.utils.contentAsStringOrNull +import com.petersamokhin.notionapi.utils.jsonArrayOrNull +import com.petersamokhin.notionapi.utils.trimNotionTextField +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement + +fun NotionCollection.title() = value.name?.trimNotionTextField() + +fun NotionCollection.description() = value.description?.trimNotionTextField() + +fun parseNotionColumn(json: Json, name: String, type: NotionColumnType, field: JsonArray?): NotionColumn { + if (field.isNullOrEmpty()) return NotionColumn.SingleValue(name, type, null) + val masterList = field.filterIsInstance() + + return when (type) { + NotionColumnType.Title, NotionColumnType.Text, NotionColumnType.Number, + NotionColumnType.Checkbox, NotionColumnType.Select, NotionColumnType.MultiSelect -> { + val label = masterList.firstOrNull()?.getOrNull(0)?.contentAsStringOrNull + ?: return NotionColumn.SingleValue(name, type, null) + + return NotionColumn.SingleValue( + name = name, type = type, + value = when (type) { + NotionColumnType.Title -> NotionProperty.Value.Title(label) + NotionColumnType.Text -> NotionProperty.Value.Text(label) + NotionColumnType.Number -> NotionProperty.Value.Number(label.toDouble()) + NotionColumnType.Checkbox -> NotionProperty.Value.Checkbox(label == NotionBooleanSerializer.NOTION_TRUE) + NotionColumnType.Select -> NotionProperty.Value.Select(label) + NotionColumnType.MultiSelect -> NotionProperty.Value.MultiSelect(label.split(",")) + else -> throw IllegalStateException("exhaustive") + }.let { NotionProperty(label = label, it) } + ) + } + NotionColumnType.Email, NotionColumnType.Url, NotionColumnType.PhoneNumber, + NotionColumnType.Person, NotionColumnType.File -> { + NotionColumn.MultiValue( + name = name, + type = type, + values = masterList.map { currentItemList -> + currentItemList.filterIsInstance().mapNotNull { + currentItemList.firstOrNull()?.contentAsStringOrNull?.let { label -> + it.getOrNull(0)?.jsonArrayOrNull?.getOrNull(1)?.contentAsStringOrNull?.let { + label to it + } + } + } + }.flatten() + .map { (label, item) -> + val v = when (type) { + NotionColumnType.Email -> NotionProperty.Value.Entry.Email(item) + NotionColumnType.Url -> NotionProperty.Value.Entry.Link(item) + NotionColumnType.PhoneNumber -> NotionProperty.Value.Entry.PhoneNumber(item) + NotionColumnType.Person -> NotionProperty.Value.Entry.Person(item) + NotionColumnType.File -> NotionProperty.Value.Entry.File(item) + else -> null + } + + NotionProperty(label, v) + } + ) + } + NotionColumnType.Date -> { + NotionColumn.SingleValue( + name = name, + type = type, + value = NotionProperty( + label = masterList.firstOrNull()?.getOrNull(0)?.contentAsStringOrNull + ?: return NotionColumn.SingleValue(name, type, null), + value = masterList.firstOrNull()?.getOrNull(1) + ?.jsonArrayOrNull?.getOrNull(0) + ?.jsonArrayOrNull?.getOrNull(1) + ?.let(json::decodeFromJsonElement) + ) + ) + } + NotionColumnType.LastEditedTime, NotionColumnType.LastEditedBy, + NotionColumnType.CreatedTime, NotionColumnType.CreatedBy, + NotionColumnType.Rollup, NotionColumnType.Relation, NotionColumnType.Formula -> { + NotionColumn.SingleValue(name, type, null) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionResponseMapper.kt b/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionResponseMapper.kt index cd931e2..973305b 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionResponseMapper.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/mapper/NotionResponseMapper.kt @@ -1,93 +1,68 @@ package com.petersamokhin.notionapi.mapper import com.petersamokhin.notionapi.model.* -import com.petersamokhin.notionapi.serializer.NotionBooleanSerializer -import com.petersamokhin.notionapi.utils.trimNotionTextField -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive +import com.petersamokhin.notionapi.model.response.NotionBlock +import com.petersamokhin.notionapi.model.response.NotionCollection +import com.petersamokhin.notionapi.model.response.NotionResponse +import kotlinx.serialization.json.* -fun NotionCollection.mapTable(blocks: List): NotionTable>> { - val rows = blocks.map { - val props = it.value.properties - - mutableMapOf>().also { map -> - props?.keys?.forEach { innerRowKey -> - val schemaItem = value.schema[innerRowKey] ?: return@forEach +fun NotionResponse.mapTable(json: Json, sortColumns: Boolean = false): NotionTable? { + val collectionId = recordMap.collectionsMap?.keys?.firstOrNull() ?: return null + val collection = recordMap.collectionsMap[collectionId] - schemaItem.name.also { name -> - val fieldText = props[innerRowKey]?.trimNotionTextField() - val value: NotionColumn.Entry<*> = when (schemaItem.type) { - NotionColumnType.Title, NotionColumnType.Text, NotionColumnType.Select -> { - NotionColumn.Entry.Text(name, fieldText) - } - NotionColumnType.Number -> { - NotionColumn.Entry.Number(name, fieldText?.toDoubleOrNull()) - } - NotionColumnType.Checkbox -> { - NotionColumn.Entry.Bool(name, fieldText?.equals(NotionBooleanSerializer.NOTION_TRUE)) - } - NotionColumnType.MultiSelect -> { - NotionColumn.Entry.TextList(name, fieldText?.split(",")) - } - } + val collectionViewId = recordMap.collectionViewsMap?.keys?.firstOrNull() + val collectionView = recordMap.collectionViewsMap?.get(collectionViewId) + val collectionViewFormat = collectionView?.value?.format - map[name] = NotionColumn(name, schemaItem.type, value) - } - } - } + val sortMap = if (sortColumns && collectionViewFormat != null) { + collectionViewFormat.tableProperties.mapIndexed { index, item -> item.property to index }.toMap() + } else { + null } - val schema = value.schema.mapKeys { it.value.name } + val blocks = result?.blockIds?.mapNotNull { recordMap.blocksMap?.get(it) } - return NotionTable(rows, schema) + return blocks?.let { collection?.mapTable(json, it, sortMap) } } -fun NotionResponse.mapTable(): NotionTable>>? { - val collectionId = recordMap.collectionsMap.keys.firstOrNull() - val collection = recordMap.collectionsMap[collectionId] - val blocks = result?.blockIds?.map { recordMap.blocksMap.getValue(it) } - - return blocks?.let { collection?.mapTable(it) } -} - -fun NotionResponse.mapCollectionToJsonArray(): JsonArray? { - val collectionId = recordMap.collectionsMap.keys.firstOrNull() ?: return null - val collection = recordMap.collectionsMap[collectionId] ?: return null - val blocks = result?.blockIds?.map { recordMap.blocksMap.getValue(it) } ?: return null - - val list = blocks.map { - val props = it.value.properties ?: return null +fun NotionCollection.mapTable( + json: Json, + blocks: List, + sortMap: Map? = null +): NotionTable { + val rows: List = blocks.map { block -> + val props = block.value.properties + val propsKeys = props?.keys?.let { propsKeys -> + if (sortMap != null) { + propsKeys.sortedBy { sortMap[it] } + } else { + propsKeys + } + } + val rowItems = mutableMapOf() - val map = props.keys.mapNotNull { innerRowKey -> - val schemaItem = collection.value.schema[innerRowKey] ?: return@mapNotNull null + propsKeys?.forEach { innerRowKey -> + val schemaItem = value.schema[innerRowKey] ?: return@forEach - schemaItem.name.let { name -> - val fieldText = props[innerRowKey]?.trimNotionTextField() - val value = when (schemaItem.type) { - NotionColumnType.Title, NotionColumnType.Text, NotionColumnType.Select -> { - JsonPrimitive(fieldText) - } - NotionColumnType.Number -> { - JsonPrimitive(fieldText?.toDoubleOrNull()) - } - NotionColumnType.Checkbox -> { - JsonPrimitive(fieldText?.equals(NotionBooleanSerializer.NOTION_TRUE)) - } - NotionColumnType.MultiSelect -> { - fieldText?.let { - JsonArray(fieldText.split(",").map(::JsonPrimitive)) - } ?: JsonNull - } - } + schemaItem.name.also { name -> + val field = props[innerRowKey] - name to value + rowItems[name] = parseNotionColumn(json, name, schemaItem.type, field) } - }.toMap() + } - JsonObject(map) + NotionRow( + properties = rowItems, + metaInfo = NotionRow.MetaInfo( + lastEditedBy = block.value.lastEditedById, + lastEditedTime = block.value.lastEditedTime, + createdBy = block.value.createdById, + createdTime = block.value.createdTime + ) + ) } - return JsonArray(list) + val schema = value.schema.mapKeys { it.value.name } + + return NotionTable(rows, schema) } \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionCredentials.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/NotionCredentials.kt new file mode 100644 index 0000000..7d246c4 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/NotionCredentials.kt @@ -0,0 +1,9 @@ +package com.petersamokhin.notionapi.model + +import kotlinx.serialization.Serializable + +@Serializable +data class NotionCredentials( + val email: String, + val password: String +) diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionMappedResponse.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/NotionMappedResponse.kt deleted file mode 100644 index 12b3ed2..0000000 --- a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionMappedResponse.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.petersamokhin.notionapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -data class NotionTable( - val rows: List, - val schema: Map -) - -data class NotionColumn(val name: String, val type: NotionColumnType, val value: Entry?) { - @Serializable - sealed class Entry { - abstract val key: String - abstract val value: T? - - @Serializable - @SerialName("text") - data class Text(override val key: String, override val value: String? = null): Entry() - - @Serializable - @SerialName("text_list") - data class TextList(override val key: String, override val value: List? = null): Entry>() - - @Serializable - @SerialName("boolean") - data class Bool(override val key: String, override val value: Boolean? = null): Entry() - - @Serializable - @SerialName("number") - data class Number(override val key: String, override val value: Double? = null): Entry() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionResponse.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/NotionResponse.kt deleted file mode 100644 index 7454c37..0000000 --- a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionResponse.kt +++ /dev/null @@ -1,205 +0,0 @@ -package com.petersamokhin.notionapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class NotionResponse( - val recordMap: NotionRecordMap, - val result: NotionResult? = null -) - -@Serializable -data class NotionRecordMap( - @SerialName("block") val blocksMap: Map, - @SerialName("collection") val collectionsMap: Map, - @SerialName("collection_view") val collectionViewsMap: Map -) - -@Serializable -data class NotionBlock( - val role: String, - val value: NotionBlockValue -) - -@Serializable -data class NotionBlockValue( - val alive: Boolean, - val version: Int, - val type: String, - @SerialName("view_ids") - val viewIds: List? = null, - @SerialName("collection_id") - val collectionId: String? = null, - @SerialName("created_time") - val createdTime: Long, - @SerialName("last_edited_time") - val lastEditedTime: Long, - @SerialName("parent_id") - val parentId: String, - @SerialName("parent_table") - val parentTable: String, - @SerialName("ignore_block_count") - val ignoreBlockCount: Boolean? = null, - @SerialName("created_by_table") - val createdByTable: String, - @SerialName("created_by_id") - val createdById: String, - @SerialName("last_edited_by_table") - val lastEditedByTable: String, - @SerialName("last_edited_by_id") - val lastEditedById: String, - @SerialName("shard_id") - val shardId: Int, - @SerialName("space_id") - val spaceId: String, - val properties: Map>>? = null -) - -@Serializable -data class NotionResult( - val blockIds: List? = null, - val aggregationResults: List? = null, - val total: Int? = null, - val type: String? = null -) - -@Serializable -data class NotionCollectionAggregationResult( - val id: String? = null, - val value: Int? = null -) - -@Serializable -data class NotionCollection( - val role: String, - val value: NotionCollectionValue -) - -@Serializable -data class NotionCollectionView( - val role: String, - val value: NotionCollectionViewValue -) - -@Serializable -data class NotionCollectionCoverFormat( - @SerialName("collection_cover_position") - val collectionCoverPosition: Double -) - -@Serializable -data class NotionCollectionValue( - val id: String, - val name: List>? = null, - @SerialName("parent_id") - val parentId: String, - @SerialName("parent_table") - val parentTable: String, - val version: Int, - val alive: Boolean, - val migrated: Boolean, - val cover: String? = null, - val description: List>? = null, - val format: NotionCollectionCoverFormat? = null, - val icon: String? = null, - val schema: Map -) - -@Serializable -data class NotionCollectionViewValue( - val id: String, - val version: Int, - val type: String, - val name: String, - val alive: Boolean, - @SerialName("parent_id") - val parentId: String, - @SerialName("parent_table") - val parentTable: String -) - -@Serializable -sealed class NotionCollectionColumnSchema { - abstract val name: String - abstract val type: NotionColumnType - - @Serializable - @SerialName("title") - data class Title(override val name: String, override val type: NotionColumnType): NotionCollectionColumnSchema() - - @Serializable - @SerialName("text") - data class Text(override val name: String, override val type: NotionColumnType): NotionCollectionColumnSchema() - - @Serializable - @SerialName("number") - data class Number(override val name: String, override val type: NotionColumnType, @SerialName("number_format") val numberFormat: String): NotionCollectionColumnSchema() { - @Serializable - enum class Format { - @SerialName("number") - Number, - @SerialName("number_with_commas") - NumberWithCommas, - @SerialName("percent") - Percent, - @SerialName("dollar") - Dollar, - @SerialName("euro") - Euro, - @SerialName("pound") - Pound, - @SerialName("yen") - Yen, - @SerialName("ruble") - Ruble, - @SerialName("rupee") - Rupee, - @SerialName("won") - Won, - @SerialName("yuan") - Yuan, - @SerialName("real") - Real, - @SerialName("lira") - Lira, - @SerialName("rupiah") - Rupiah - } - } - - @Serializable - @SerialName("email") - data class Email(override val name: String, override val type: NotionColumnType): NotionCollectionColumnSchema() - - @Serializable - @SerialName("checkbox") - data class Checkbox(override val name: String, override val type: NotionColumnType): NotionCollectionColumnSchema() - - @Serializable - @SerialName("select") - data class Select(override val name: String, override val type: NotionColumnType, val options: List): NotionCollectionColumnSchema() - - @Serializable - @SerialName("multi_select") - data class MultiSelect(override val name: String, override val type: NotionColumnType, val options: List): NotionCollectionColumnSchema() -} - -@Serializable -data class NotionSelectOption(val id: String, val color: String, val value: String) - -@Serializable -enum class NotionColumnType { - @SerialName("title") - Title, - @SerialName("text") - Text, - @SerialName("number") - Number, - @SerialName("checkbox") - Checkbox, - @SerialName("select") - Select, - @SerialName("multi_select") - MultiSelect -} \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionTable.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/NotionTable.kt new file mode 100644 index 0000000..aab86dc --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/NotionTable.kt @@ -0,0 +1,167 @@ +package com.petersamokhin.notionapi.model + +import com.petersamokhin.notionapi.model.response.NotionCollectionColumnSchema +import com.petersamokhin.notionapi.model.response.NotionColumnType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotionTable( + val rows: List, + val schema: Map +) + +@Serializable +data class NotionRow( + val properties: Map, + @SerialName("meta_info") + val metaInfo: MetaInfo +) { + @Serializable + data class MetaInfo( + val lastEditedBy: String, + val lastEditedTime: Long, + val createdBy: String, + val createdTime: Long + ) +} + +@Serializable +sealed class NotionColumn { + abstract val name: String + abstract val type: NotionColumnType + + @Serializable + @SerialName("single_value") + data class SingleValue( + override val name: String, + override val type: NotionColumnType, + val value: NotionProperty? + ) : NotionColumn() + + @Serializable + @SerialName("multi_value") + data class MultiValue( + override val name: String, + override val type: NotionColumnType, + val values: List + ) : NotionColumn() +} + +@Serializable +data class NotionProperty(val label: String, val value: Value? = null) { + + @Serializable + sealed class Value { + @Serializable + @SerialName("title") + data class Title(val text: String) : Value() + + @Serializable + @SerialName("text") + data class Text(val text: String) : Value() + + @Serializable + @SerialName("checkbox") + data class Checkbox(val checked: Boolean) : Value() + + @Serializable + @SerialName("number") + data class Number(val number: Double) : Value() + + @Serializable + @SerialName("select") + data class Select(val option: String) : Value() + + @Serializable + @SerialName("multi_select") + data class MultiSelect(val options: List) : Value() + + @Serializable + @SerialName("entry") + sealed class Entry : Value() { + @Serializable + @SerialName("person") + data class Person(val id: String) : Entry() + + @Serializable + @SerialName("link") + data class Link(val url: String) : Entry() + + @Serializable + @SerialName("file") + data class File(val url: String) : Entry() + + @Serializable + @SerialName("email") + data class Email(val email: String) : Entry() + + @Serializable + @SerialName("phone_number") + data class PhoneNumber(@SerialName("phone_number") val phoneNumber: String) : Entry() + + @Serializable + @SerialName("date") + data class Date( + val type: Type, + @SerialName("start_date") + val startDate: String, + @SerialName("time_zone") + val timeZone: String? = null, + val reminder: Reminder? = null, + @SerialName("end_date") + val endDate: String? = null, + @SerialName("end_time") + val endTime: String? = null, + @SerialName("start_time") + val startTime: String? = null + ) : Entry() { + @Serializable + enum class Type { + @SerialName("date") + Date, + + @SerialName("datetime") + DateTime, + + @SerialName("datetimerange") + DateTimeRange + } + + @Serializable + data class Reminder( + val time: String, + val unit: ReminderTimeUnit, + val value: Int + ) { + @Serializable + enum class ReminderTimeUnit { + @SerialName("week") + Week, + + @SerialName("day") + Day, + + @SerialName("hour") + Hour, + + @SerialName("monute") + Minute + } + } + } + + @Serializable + enum class Type { + @SerialName("u") + Person, + + @SerialName("d") + Date, + + @SerialName("a") + Link + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/error/NotionAuthException.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/error/NotionAuthException.kt new file mode 100644 index 0000000..bc83741 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/error/NotionAuthException.kt @@ -0,0 +1,3 @@ +package com.petersamokhin.notionapi.model.error + +class NotionAuthException(message: String) : Exception(message) \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionRequestBody.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/request/NotionRequestBody.kt similarity index 90% rename from src/main/kotlin/com/petersamokhin/notionapi/model/NotionRequestBody.kt rename to src/main/kotlin/com/petersamokhin/notionapi/model/request/NotionRequestBody.kt index 66f92dc..b3499cb 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/model/NotionRequestBody.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/request/NotionRequestBody.kt @@ -1,4 +1,4 @@ -package com.petersamokhin.notionapi.model +package com.petersamokhin.notionapi.model.request import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollection.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollection.kt new file mode 100644 index 0000000..7c15d1e --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollection.kt @@ -0,0 +1,40 @@ +package com.petersamokhin.notionapi.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotionCollection( + val role: String, + val value: NotionCollectionValue +) + +@Serializable +data class NotionCollectionValue( + val id: String, + val name: NotionTextField? = null, + @SerialName("parent_id") + val parentId: String, + @SerialName("parent_table") + val parentTable: String, + val version: Int, + val alive: Boolean, + val migrated: Boolean, + val cover: String? = null, + val description: NotionTextField? = null, + val format: NotionCollectionCoverFormat? = null, + val icon: String? = null, + val schema: Map +) + +@Serializable +data class NotionCollectionCoverFormat( + @SerialName("collection_cover_position") + val collectionCoverPosition: Double +) + +@Serializable +data class NotionCollectionAggregationResult( + val id: String? = null, + val value: Int? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollectionColumnSchema.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollectionColumnSchema.kt new file mode 100644 index 0000000..739cd80 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollectionColumnSchema.kt @@ -0,0 +1,264 @@ +package com.petersamokhin.notionapi.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +typealias NotionTextField = List> + +@Serializable +sealed class NotionCollectionColumnSchema { + abstract val name: String + abstract val type: NotionColumnType + + @Serializable + @SerialName("title") + data class Title(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("text") + data class Text(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("number") + data class Number( + override val name: String, + override val type: NotionColumnType, + @SerialName("number_format") val numberFormat: String? = null + ) : NotionCollectionColumnSchema() { + @Serializable + enum class Format { + @SerialName("number") + Number, + + @SerialName("number_with_commas") + NumberWithCommas, + + @SerialName("percent") + Percent, + + @SerialName("dollar") + Dollar, + + @SerialName("euro") + Euro, + + @SerialName("pound") + Pound, + + @SerialName("yen") + Yen, + + @SerialName("ruble") + Ruble, + + @SerialName("rupee") + Rupee, + + @SerialName("won") + Won, + + @SerialName("yuan") + Yuan, + + @SerialName("real") + Real, + + @SerialName("lira") + Lira, + + @SerialName("rupiah") + Rupiah + } + } + + @Serializable + @SerialName("email") + data class Email(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("checkbox") + data class Checkbox(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("select") + data class Select( + override val name: String, + override val type: NotionColumnType, + val options: List + ) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("multi_select") + data class MultiSelect( + override val name: String, + override val type: NotionColumnType, + val options: List + ) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("url") + data class Url(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("date") + data class Date( + override val name: String, + override val type: NotionColumnType, + @SerialName("time_format") val timeFormat: String? = null, + @SerialName("date_format") val dateFormat: String? + ) : NotionCollectionColumnSchema() { + @Serializable + enum class TimeFormat { + @SerialName("LT") + H_12, + + @SerialName("H:mm") + H_24; + } + + @Serializable + enum class DateFormat { + @SerialName("ll") // or null + Full, + + @SerialName("relative") + Relative, + + @SerialName("MM/DD/YYYY") + MM_DD_YYYY, + + @SerialName("DD/MM/YYYY") + DD_MM_YYYY, + + @SerialName("YYYY/MM/DD") + YYYY_MM_DD; + } + } + + @Serializable + @SerialName("person") + data class Person( + override val name: String, + override val type: NotionColumnType + ) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("phone_number") + data class PhoneNumber( + override val name: String, + override val type: NotionColumnType + ) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("file") + data class File(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("last_edited_time") + data class LastEditedTime(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("last_edited_by") + data class LastEditedBy(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("created_time") + data class CreatedTime(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("created_by") + data class CreatedBy(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("rollup") + data class Rollup(override val name: String, override val type: NotionColumnType) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("relation") + data class Relation( + override val name: String, + override val type: NotionColumnType, + val property: String, + @SerialName("collection_id") + val collectionId: String + ) : NotionCollectionColumnSchema() + + @Serializable + @SerialName("formula") + data class Formula( + override val name: String, + override val type: NotionColumnType, + val formula: JsonElement + ) : NotionCollectionColumnSchema() +} + +@Serializable +data class NotionPropertyFormat( + val width: Int? = null, + val visible: Boolean, + val property: String +) + +@Serializable +data class NotionSelectOption(val id: String, val color: String, val value: String) + +@Serializable +enum class NotionColumnType { + @SerialName("title") + Title, + + @SerialName("text") + Text, + + @SerialName("number") + Number, + + @SerialName("checkbox") + Checkbox, + + @SerialName("select") + Select, + + @SerialName("multi_select") + MultiSelect, + + @SerialName("email") + Email, + + @SerialName("url") + Url, + + @SerialName("date") + Date, + + @SerialName("person") + Person, + + @SerialName("phone_number") + PhoneNumber, + + @SerialName("file") + File, + + @SerialName("last_edited_time") + LastEditedTime, + + @SerialName("last_edited_by") + LastEditedBy, + + @SerialName("created_time") + CreatedTime, + + @SerialName("created_by") + CreatedBy, + + @SerialName("rollup") + Rollup, + + @SerialName("relation") + Relation, + + @SerialName("formula") + Formula +} \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollectionView.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollectionView.kt new file mode 100644 index 0000000..75e99c1 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionCollectionView.kt @@ -0,0 +1,32 @@ +package com.petersamokhin.notionapi.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotionCollectionView( + val role: String, + val value: NotionCollectionViewValue +) + +@Serializable +data class NotionCollectionViewValue( + val id: String, + val version: Int, + val type: String, + val name: String, + val alive: Boolean, + @SerialName("parent_id") + val parentId: String, + @SerialName("parent_table") + val parentTable: String, + val format: NotionCollectionViewValueFormat? = null +) + +@Serializable +data class NotionCollectionViewValueFormat( + @SerialName("table_wrap") + val tableWrap: Boolean, + @SerialName("table_properties") + val tableProperties: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionResponse.kt b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionResponse.kt new file mode 100644 index 0000000..1970088 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/model/response/NotionResponse.kt @@ -0,0 +1,66 @@ +package com.petersamokhin.notionapi.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray + +@Serializable +data class NotionResponse( + val recordMap: NotionRecordMap, + val result: NotionResult? = null +) + +@Serializable +data class NotionRecordMap( + @SerialName("block") val blocksMap: Map? = null, + @SerialName("collection") val collectionsMap: Map? = null, + @SerialName("collection_view") val collectionViewsMap: Map? = null +) + +@Serializable +data class NotionBlock( + val role: String, + val value: NotionBlockValue +) + +@Serializable +data class NotionBlockValue( + val alive: Boolean, + val version: Int, + val type: String, + @SerialName("view_ids") + val viewIds: List? = null, + @SerialName("collection_id") + val collectionId: String? = null, + @SerialName("created_time") + val createdTime: Long, + @SerialName("last_edited_time") + val lastEditedTime: Long, + @SerialName("parent_id") + val parentId: String, + @SerialName("parent_table") + val parentTable: String, + @SerialName("ignore_block_count") + val ignoreBlockCount: Boolean? = null, + @SerialName("created_by_table") + val createdByTable: String, + @SerialName("created_by_id") + val createdById: String, + @SerialName("last_edited_by_table") + val lastEditedByTable: String, + @SerialName("last_edited_by_id") + val lastEditedById: String, + @SerialName("shard_id") + val shardId: Int, + @SerialName("space_id") + val spaceId: String, + val properties: Map? = null +) + +@Serializable +data class NotionResult( + val blockIds: List? = null, + val aggregationResults: List? = null, + val total: Int? = null, + val type: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/request/QueryNotionCollectionRequest.kt b/src/main/kotlin/com/petersamokhin/notionapi/request/QueryNotionCollectionRequest.kt index c4d6bcd..fc5d339 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/request/QueryNotionCollectionRequest.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/request/QueryNotionCollectionRequest.kt @@ -1,17 +1,14 @@ package com.petersamokhin.notionapi.request -import com.petersamokhin.notionapi.model.LoadPageChunkRequestBody -import com.petersamokhin.notionapi.model.NotionResponse -import com.petersamokhin.notionapi.model.QueryCollectionRequestBody +import com.petersamokhin.notionapi.model.request.LoadPageChunkRequestBody +import com.petersamokhin.notionapi.model.request.QueryCollectionRequestBody +import com.petersamokhin.notionapi.model.response.NotionResponse import com.petersamokhin.notionapi.request.base.NotionRequest -import io.ktor.client.HttpClient -import io.ktor.client.request.post -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.util.KtorExperimentalAPI +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.http.* class QueryNotionCollectionRequest(httpClient: HttpClient) : NotionRequest(httpClient) { - @KtorExperimentalAPI override suspend fun execute(requestBody: QueryCollectionRequestBody): NotionResponse { return httpClient.post("$API_BASE_URL/${Endpoint.QUERY_COLLECTION}") { headers.appendAll(BASE_HEADERS) @@ -22,7 +19,6 @@ class QueryNotionCollectionRequest(httpClient: HttpClient) : NotionRequest(httpClient) { - @KtorExperimentalAPI override suspend fun execute(requestBody: LoadPageChunkRequestBody): NotionResponse { return httpClient.post("$API_BASE_URL/${Endpoint.LOAD_PAGE_CHUNK}") { headers.appendAll(BASE_HEADERS) diff --git a/src/main/kotlin/com/petersamokhin/notionapi/request/base/NotionRequest.kt b/src/main/kotlin/com/petersamokhin/notionapi/request/base/NotionRequest.kt index d31eae9..e8b5a4b 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/request/base/NotionRequest.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/request/base/NotionRequest.kt @@ -1,18 +1,19 @@ package com.petersamokhin.notionapi.request.base -import com.petersamokhin.notionapi.model.NotionResponse +import com.petersamokhin.notionapi.model.response.NotionResponse import io.ktor.client.HttpClient import io.ktor.http.headersOf abstract class NotionRequest(protected val httpClient: HttpClient) { abstract suspend fun execute(requestBody: T): NotionResponse - protected object Endpoint { + internal object Endpoint { const val QUERY_COLLECTION = "queryCollection" const val LOAD_PAGE_CHUNK = "loadPageChunk" + const val LOGIN_WITH_EMAIL = "loginWithEmail" } - protected companion object { + companion object { const val API_BASE_URL = "https://www.notion.so/api/v3" val BASE_HEADERS = headersOf( "Accept-Language" to listOf("en-US,en;q=0.9"), diff --git a/src/main/kotlin/com/petersamokhin/notionapi/serializer/NotionBooleanSerializer.kt b/src/main/kotlin/com/petersamokhin/notionapi/serializer/NotionBooleanSerializer.kt index f6a6899..b31cf0b 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/serializer/NotionBooleanSerializer.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/serializer/NotionBooleanSerializer.kt @@ -21,6 +21,6 @@ object NotionBooleanSerializer : KSerializer { override fun deserialize(decoder: Decoder): Boolean = decoder.decodeString() == NOTION_TRUE - const val NOTION_TRUE = "Yes" + const val NOTION_TRUE = "Yes" // ingeniously const val NOTION_FALSE = "No" } \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/utils/JsonUtils.kt b/src/main/kotlin/com/petersamokhin/notionapi/utils/JsonUtils.kt new file mode 100644 index 0000000..f28eb74 --- /dev/null +++ b/src/main/kotlin/com/petersamokhin/notionapi/utils/JsonUtils.kt @@ -0,0 +1,9 @@ +package com.petersamokhin.notionapi.utils + +import kotlinx.serialization.json.* + +val JsonElement.jsonArrayOrNull: JsonArray? + get() = try { jsonArray } catch (e: Exception) { null } + +val JsonElement.contentAsStringOrNull: String? + get() = try { jsonPrimitive.contentOrNull } catch (e: Exception) { null } \ No newline at end of file diff --git a/src/main/kotlin/com/petersamokhin/notionapi/utils/NotionUtils.kt b/src/main/kotlin/com/petersamokhin/notionapi/utils/NotionUtils.kt index adeb9aa..ca1ef84 100644 --- a/src/main/kotlin/com/petersamokhin/notionapi/utils/NotionUtils.kt +++ b/src/main/kotlin/com/petersamokhin/notionapi/utils/NotionUtils.kt @@ -1,6 +1,6 @@ package com.petersamokhin.notionapi.utils -import com.petersamokhin.notionapi.model.NotionCollection +import com.petersamokhin.notionapi.model.response.NotionCollection private const val DASH_ID_LENGTH_VALID = 36 private const val DASH_ID_CLEAN_LENGTH_VALID = 32 @@ -34,7 +34,4 @@ fun String.isValidDashId(): Boolean { } fun List>.trimNotionTextField() = - flatten().joinToString("") - -fun NotionCollection.title() = value.name?.trimNotionTextField() -fun NotionCollection.description() = value.description?.trimNotionTextField() \ No newline at end of file + flatten().joinToString("") \ No newline at end of file