diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/ApiResult.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/ApiResult.kt new file mode 100644 index 0000000..73fa4ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/ApiResult.kt @@ -0,0 +1,27 @@ +package com.avinal.memos.api + +sealed class ApiResult { + data class Success(val data: T) : ApiResult() + data class Error(val code: Int, val message: String) : ApiResult() + data class NetworkError(val exception: Throwable) : ApiResult() +} + +inline fun ApiResult.map(transform: (T) -> R): ApiResult = when (this) { + is ApiResult.Success -> ApiResult.Success(transform(data)) + is ApiResult.Error -> this + is ApiResult.NetworkError -> this +} + +inline fun ApiResult.onSuccess(action: (T) -> Unit): ApiResult { + if (this is ApiResult.Success) action(data) + return this +} + +inline fun ApiResult.onError(action: (String) -> Unit): ApiResult { + when (this) { + is ApiResult.Error -> action(message) + is ApiResult.NetworkError -> action(exception.message ?: "Network error") + else -> {} + } + return this +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/HttpClientFactory.kt new file mode 100644 index 0000000..413eb5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/HttpClientFactory.kt @@ -0,0 +1,33 @@ +package com.avinal.memos.api + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.plugin +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +object HttpClientFactory { + + fun create(tokenProvider: () -> String?): HttpClient { + val client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + }) + } + } + + client.plugin(HttpSend).intercept { request -> + tokenProvider()?.let { token -> + request.headers.append(HttpHeaders.Authorization, "Bearer $token") + } + execute(request) + } + + return client + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt new file mode 100644 index 0000000..3033d96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt @@ -0,0 +1,130 @@ +package com.avinal.memos.api + +import com.avinal.memos.api.model.CreateMemoRequest +import com.avinal.memos.api.model.FieldMask +import com.avinal.memos.api.model.ListMemosResponse +import com.avinal.memos.api.model.MemoDto +import com.avinal.memos.api.model.ReactionDto +import com.avinal.memos.api.model.UpsertReactionRequest +import com.avinal.memos.api.model.UpdateMemoBody +import com.avinal.memos.api.model.UpdateMemoRequest +import com.avinal.memos.api.model.UserDto +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +class MemosApiClient( + private val httpClient: HttpClient, + val baseUrlProvider: () -> String, +) { + private fun url(path: String): String = "${baseUrlProvider().trimEnd('/')}/api/v1$path" + + suspend fun getMe(): ApiResult = apiCall { + httpClient.get(url("/auth/me")).body() + } + + suspend fun listMemos( + pageSize: Int = 20, + pageToken: String = "", + filter: String = "", + ): ApiResult = apiCall { + httpClient.get(url("/memos")) { + parameter("pageSize", pageSize) + if (pageToken.isNotEmpty()) parameter("pageToken", pageToken) + if (filter.isNotEmpty()) parameter("filter", filter) + }.body() + } + + suspend fun getMemo(id: String): ApiResult = apiCall { + httpClient.get(url("/memos/$id")).body() + } + + suspend fun createMemo( + content: String, + visibility: String = "PRIVATE", + ): ApiResult = apiCall { + httpClient.post(url("/memos")) { + contentType(ContentType.Application.Json) + setBody(CreateMemoRequest(content = content, visibility = visibility)) + }.body() + } + + suspend fun updateMemo( + id: String, + content: String? = null, + visibility: String? = null, + pinned: Boolean? = null, + state: String? = null, + ): ApiResult = apiCall { + val paths = buildList { + if (content != null) add("content") + if (visibility != null) add("visibility") + if (pinned != null) add("pinned") + if (state != null) add("state") + } + httpClient.patch(url("/memos/$id")) { + parameter("updateMask", paths.joinToString(",")) + contentType(ContentType.Application.Json) + setBody(UpdateMemoBody( + content = content, + visibility = visibility, + pinned = pinned, + state = state, + )) + }.body() + } + + suspend fun searchMemos(query: String): ApiResult = apiCall { + httpClient.get(url("/memos")) { + parameter("pageSize", 50) + parameter("filter", "content.contains(\"$query\")") + }.body() + } + + suspend fun upsertReaction(memoId: String, reactionType: String): ApiResult = apiCall { + httpClient.post(url("/memos/$memoId/reactions")) { + contentType(ContentType.Application.Json) + setBody(UpsertReactionRequest( + reaction = ReactionDto(reactionType = reactionType, contentId = "memos/$memoId") + )) + }.body() + } + + suspend fun deleteReaction(memoId: String, reactionId: String): ApiResult = apiCall { + val response = httpClient.delete(url("/memos/$memoId/reactions/$reactionId")) + if (!response.status.isSuccess()) { + throw ApiException(response.status.value, response.bodyAsText()) + } + } + + suspend fun deleteMemo(id: String): ApiResult = apiCall { + val response = httpClient.delete(url("/memos/$id")) + if (!response.status.isSuccess()) { + throw ApiException(response.status.value, response.bodyAsText()) + } + } + + private suspend inline fun apiCall(block: () -> T): ApiResult = + try { + ApiResult.Success(block()) + } catch (e: ApiException) { + ApiResult.Error(e.code, e.message ?: "Unknown error") + } catch (e: io.ktor.client.plugins.ClientRequestException) { + ApiResult.Error(e.response.status.value, e.message) + } catch (e: io.ktor.client.plugins.ServerResponseException) { + ApiResult.Error(e.response.status.value, e.message) + } catch (e: Exception) { + ApiResult.NetworkError(e) + } +} + +class ApiException(val code: Int, message: String) : Exception(message) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/Mappers.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/Mappers.kt new file mode 100644 index 0000000..d1d31b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/Mappers.kt @@ -0,0 +1,64 @@ +package com.avinal.memos.api.model + +import com.avinal.memos.domain.Attachment +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoVisibility +import com.avinal.memos.domain.Reaction +import com.avinal.memos.domain.User +import kotlin.time.Instant + +private fun String.extractId(): String = substringAfterLast("/") + +fun MemoDto.toDomain(): Memo = Memo( + id = name.extractId(), + uid = uid, + content = content, + visibility = MemoVisibility.fromApiString(visibility), + pinned = pinned, + state = state, + createTime = parseTimestamp(createTime), + updateTime = parseTimestamp(updateTime), + displayTime = parseTimestamp(displayTime.ifEmpty { updateTime }), + creator = creator.extractId(), + hasTaskList = property?.hasTaskList ?: false, + hasIncompleteTasks = property?.hasIncompleteTasks ?: false, + title = property?.title ?: "", + tags = tags, + snippet = snippet, + attachments = attachments.map { it.toDomain() }, + reactions = reactions.map { it.toDomain() }, +) + +fun AttachmentDto.toDomain(): Attachment = Attachment( + name = name, + filename = filename, + mimeType = type, + size = size.toLongOrNull() ?: 0L, + externalLink = externalLink, +) + +fun ReactionDto.toDomain(): Reaction = Reaction( + id = name.extractId(), + creator = creator.extractId(), + reactionType = reactionType, +) + +fun UserDto.toDomain(): User = User( + id = name.extractId(), + username = username, + nickname = nickname, + email = email, + avatarUrl = avatarUrl, + role = role, +) + +private fun parseTimestamp(value: String): Instant = + if (value.isNotEmpty()) { + try { + Instant.parse(value) + } catch (_: Exception) { + Instant.DISTANT_PAST + } + } else { + Instant.DISTANT_PAST + } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt new file mode 100644 index 0000000..d29cf37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt @@ -0,0 +1,96 @@ +package com.avinal.memos.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MemoDto( + val name: String = "", + val uid: String = "", + val creator: String = "", + val createTime: String = "", + val updateTime: String = "", + val displayTime: String = "", + val content: String = "", + val state: String = "NORMAL", + val visibility: String = "PRIVATE", + val pinned: Boolean = false, + val parent: String = "", + val property: MemoPropertyDto? = null, + val tags: List = emptyList(), + val snippet: String = "", + val attachments: List = emptyList(), + val reactions: List = emptyList(), + val relations: List = emptyList(), +) + +@Serializable +data class MemoPropertyDto( + val hasLink: Boolean = false, + val hasTaskList: Boolean = false, + val hasCode: Boolean = false, + val hasIncompleteTasks: Boolean = false, + val title: String = "", +) + +@Serializable +data class AttachmentDto( + val name: String = "", + val filename: String = "", + val type: String = "", + val size: String = "0", + val externalLink: String = "", + val createTime: String = "", + val memo: String = "", +) + +@Serializable +data class ReactionDto( + val name: String = "", + val creator: String = "", + val contentId: String = "", + val reactionType: String = "", + val createTime: String = "", +) + +@Serializable +data class RelationDto( + val memo: String = "", + val relatedMemo: String = "", + val type: String = "", +) + +@Serializable +data class ListMemosResponse( + val memos: List = emptyList(), + val nextPageToken: String = "", +) + +@Serializable +data class CreateMemoRequest( + val content: String, + val visibility: String = "PRIVATE", +) + +@Serializable +data class UpdateMemoBody( + val content: String? = null, + val visibility: String? = null, + val pinned: Boolean? = null, + val state: String? = null, +) + +@Serializable +data class UpdateMemoRequest( + val memo: UpdateMemoBody, + val updateMask: FieldMask, +) + +@Serializable +data class FieldMask( + val paths: List, +) + +@Serializable +data class UpsertReactionRequest( + val reaction: ReactionDto, +) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/UserDto.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/UserDto.kt new file mode 100644 index 0000000..5ac1aab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/UserDto.kt @@ -0,0 +1,15 @@ +package com.avinal.memos.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserDto( + val name: String = "", + val role: String = "USER", + val username: String = "", + val email: String = "", + val nickname: String = "", + val avatarUrl: String = "", + val createTime: String = "", + val updateTime: String = "", +) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt new file mode 100644 index 0000000..ba4dd3f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt @@ -0,0 +1,57 @@ +package com.avinal.memos.domain + +import com.avinal.memos.api.ApiResult +import com.avinal.memos.api.MemosApiClient +import com.avinal.memos.api.model.toDomain +import com.avinal.memos.util.TokenStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +class AuthRepository( + private val apiClient: MemosApiClient, + private val tokenStore: TokenStore, +) { + private val _currentUser = MutableStateFlow(null) + val currentUser: StateFlow = _currentUser.asStateFlow() + + val isLoggedIn: Flow = tokenStore.accessToken.map { it != null } + + suspend fun login(serverUrl: String, token: String): ApiResult { + val originalBaseUrl = apiClient.baseUrlProvider + val tempClient = MemosApiClient( + httpClient = com.avinal.memos.api.HttpClientFactory.create { token }, + baseUrlProvider = { serverUrl }, + ) + + return when (val result = tempClient.getMe()) { + is ApiResult.Success -> { + val user = result.data.toDomain() + tokenStore.saveCredentials(serverUrl, token) + _currentUser.value = user + ApiResult.Success(user) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun validateToken(): ApiResult { + return when (val result = apiClient.getMe()) { + is ApiResult.Success -> { + val user = result.data.toDomain() + _currentUser.value = user + ApiResult.Success(user) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun logout() { + tokenStore.clear() + _currentUser.value = null + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Memo.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Memo.kt new file mode 100644 index 0000000..8a6a823 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Memo.kt @@ -0,0 +1,39 @@ +package com.avinal.memos.domain + +import kotlin.time.Instant + +data class Memo( + val id: String, + val uid: String, + val content: String, + val visibility: MemoVisibility, + val pinned: Boolean, + val state: String, + val createTime: Instant, + val updateTime: Instant, + val displayTime: Instant, + val creator: String, + val hasTaskList: Boolean, + val hasIncompleteTasks: Boolean, + val title: String, + val tags: List, + val snippet: String, + val attachments: List = emptyList(), + val reactions: List = emptyList(), +) + +data class Attachment( + val name: String, + val filename: String, + val mimeType: String, + val size: Long, + val externalLink: String, +) { + val isImage: Boolean get() = mimeType.startsWith("image/") +} + +data class Reaction( + val id: String, + val creator: String, + val reactionType: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt new file mode 100644 index 0000000..5400a2b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt @@ -0,0 +1,167 @@ +package com.avinal.memos.domain + +import com.avinal.memos.api.ApiResult +import com.avinal.memos.api.MemosApiClient +import com.avinal.memos.api.model.toDomain +import com.avinal.memos.db.dao.MemoDao +import com.avinal.memos.db.entity.toEntity +import com.avinal.memos.db.entity.toDomain +import kotlin.time.Clock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MemoRepository( + private val apiClient: MemosApiClient, + private val memoDao: MemoDao, +) { + private var nextPageToken: String = "" + private var hasMorePages: Boolean = true + private var lastFetchTime: Long = 0L + + private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() + + private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > CACHE_TTL_MS + + fun observeMemos(): Flow> { + if (isCacheStale()) { + CoroutineScope(Dispatchers.IO).launch { refreshMemos() } + } + return memoDao.observeAll().map { entities -> entities.map { it.toDomain() } } + } + + fun observeMemo(id: String): Flow = + memoDao.observeById(id).map { it?.toDomain() } + + suspend fun getMemo(id: String): Memo? { + val cached = memoDao.getById(id) + if (cached != null) return cached.toDomain() + return when (val result = apiClient.getMemo(id)) { + is ApiResult.Success -> { + val memo = result.data.toDomain() + memoDao.upsert(memo.toEntity(nowMillis())) + memo + } + else -> null + } + } + + suspend fun refreshMemos(): ApiResult> { + nextPageToken = "" + hasMorePages = true + return when (val result = apiClient.listMemos(pageSize = 50)) { + is ApiResult.Success -> { + val memos = result.data.memos.map { it.toDomain() } + val now = nowMillis() + lastFetchTime = now + memoDao.upsertAll(memos.map { it.toEntity(now) }) + nextPageToken = result.data.nextPageToken + hasMorePages = nextPageToken.isNotEmpty() + ApiResult.Success(memos) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun loadNextPage(): ApiResult> { + if (!hasMorePages) return ApiResult.Success(emptyList()) + return when (val result = apiClient.listMemos(pageSize = 50, pageToken = nextPageToken)) { + is ApiResult.Success -> { + val memos = result.data.memos.map { it.toDomain() } + val now = nowMillis() + memoDao.upsertAll(memos.map { it.toEntity(now) }) + nextPageToken = result.data.nextPageToken + hasMorePages = nextPageToken.isNotEmpty() + ApiResult.Success(memos) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun createMemo( + content: String, + visibility: MemoVisibility = MemoVisibility.PRIVATE, + ): ApiResult { + return when (val result = apiClient.createMemo(content, visibility.toApiString())) { + is ApiResult.Success -> { + val memo = result.data.toDomain() + memoDao.upsert(memo.toEntity(nowMillis())) + ApiResult.Success(memo) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun updateMemo( + id: String, + content: String? = null, + visibility: MemoVisibility? = null, + pinned: Boolean? = null, + ): ApiResult { + return when (val result = apiClient.updateMemo( + id = id, + content = content, + visibility = visibility?.toApiString(), + pinned = pinned, + )) { + is ApiResult.Success -> { + val memo = result.data.toDomain() + memoDao.upsert(memo.toEntity(nowMillis())) + ApiResult.Success(memo) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun reactToMemo(memoId: String, emoji: String): ApiResult { + return when (apiClient.upsertReaction(memoId, emoji)) { + is ApiResult.Success -> { + refreshMemos() + ApiResult.Success(Unit) + } + is ApiResult.Error -> ApiResult.Error(0, "Failed to react") + is ApiResult.NetworkError -> ApiResult.NetworkError(Exception("Network error")) + } + } + + suspend fun archiveMemo(id: String): ApiResult { + return when (val result = apiClient.updateMemo(id = id, state = "ARCHIVED")) { + is ApiResult.Success -> { + val memo = result.data.toDomain() + memoDao.deleteById(id) + ApiResult.Success(memo) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun deleteMemo(id: String): ApiResult { + return when (val result = apiClient.deleteMemo(id)) { + is ApiResult.Success -> { + memoDao.deleteById(id) + result + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + suspend fun clearCache() { + memoDao.deleteAll() + lastFetchTime = 0L + nextPageToken = "" + hasMorePages = true + } + + companion object { + private const val CACHE_TTL_MS = 5 * 60 * 1000L + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoVisibility.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoVisibility.kt new file mode 100644 index 0000000..47c6504 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoVisibility.kt @@ -0,0 +1,12 @@ +package com.avinal.memos.domain + +enum class MemoVisibility { + PRIVATE, PROTECTED, PUBLIC; + + fun toApiString(): String = name + + companion object { + fun fromApiString(value: String): MemoVisibility = + entries.firstOrNull { it.name == value } ?: PRIVATE + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt new file mode 100644 index 0000000..745434b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt @@ -0,0 +1,17 @@ +package com.avinal.memos.domain + +import kotlinx.datetime.LocalDate + +data class Task( + val id: String, + val memoId: String, + val lineIndex: Int, + val text: String, + val rawText: String = text, + val originalLine: String = "", + val isCompleted: Boolean, + val dueDate: LocalDate? = null, + val priority: Int? = null, + val labels: List = emptyList(), + val lists: List = emptyList(), +) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/User.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/User.kt new file mode 100644 index 0000000..c237547 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/User.kt @@ -0,0 +1,10 @@ +package com.avinal.memos.domain + +data class User( + val id: String, + val username: String, + val nickname: String, + val email: String, + val avatarUrl: String, + val role: String, +)