1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add data layer: Memos API client, domain models, repository

API client (Ktor):
- Auth via Bearer token HttpSend interceptor
- CRUD endpoints: listMemos, getMemo, createMemo, updateMemo, deleteMemo
- PATCH uses updateMask as query parameter (gRPC-Gateway format)
- Reaction upsert/delete endpoints
- ApiResult sealed class for error handling

Domain models:
- Memo with string IDs (Memos uses short UUIDs, not integers)
- Attachment, Reaction, User, Task, MemoVisibility
- MemoRepository: offline-first with Room cache, 5-min TTL
- AuthRepository: PAT login, token validation

DTOs match actual Memos API JSON (camelCase, no @SerialName needed).

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-19 17:07:50 +05:30
parent f685856b9e
commit b65673093a
12 changed files with 667 additions and 0 deletions
@@ -0,0 +1,27 @@
package com.avinal.memos.api
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
data class NetworkError(val exception: Throwable) : ApiResult<Nothing>()
}
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
is ApiResult.Success -> ApiResult.Success(transform(data))
is ApiResult.Error -> this
is ApiResult.NetworkError -> this
}
inline fun <T> ApiResult<T>.onSuccess(action: (T) -> Unit): ApiResult<T> {
if (this is ApiResult.Success) action(data)
return this
}
inline fun <T> ApiResult<T>.onError(action: (String) -> Unit): ApiResult<T> {
when (this) {
is ApiResult.Error -> action(message)
is ApiResult.NetworkError -> action(exception.message ?: "Network error")
else -> {}
}
return this
}
@@ -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
}
}
@@ -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<UserDto> = apiCall {
httpClient.get(url("/auth/me")).body()
}
suspend fun listMemos(
pageSize: Int = 20,
pageToken: String = "",
filter: String = "",
): ApiResult<ListMemosResponse> = 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<MemoDto> = apiCall {
httpClient.get(url("/memos/$id")).body()
}
suspend fun createMemo(
content: String,
visibility: String = "PRIVATE",
): ApiResult<MemoDto> = 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<MemoDto> = 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<ListMemosResponse> = apiCall {
httpClient.get(url("/memos")) {
parameter("pageSize", 50)
parameter("filter", "content.contains(\"$query\")")
}.body()
}
suspend fun upsertReaction(memoId: String, reactionType: String): ApiResult<ReactionDto> = 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<Unit> = 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<Unit> = apiCall {
val response = httpClient.delete(url("/memos/$id"))
if (!response.status.isSuccess()) {
throw ApiException(response.status.value, response.bodyAsText())
}
}
private suspend inline fun <T> apiCall(block: () -> T): ApiResult<T> =
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)
@@ -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
}
@@ -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<String> = emptyList(),
val snippet: String = "",
val attachments: List<AttachmentDto> = emptyList(),
val reactions: List<ReactionDto> = emptyList(),
val relations: List<RelationDto> = 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<MemoDto> = 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<String>,
)
@Serializable
data class UpsertReactionRequest(
val reaction: ReactionDto,
)
@@ -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 = "",
)
@@ -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<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
val isLoggedIn: Flow<Boolean> = tokenStore.accessToken.map { it != null }
suspend fun login(serverUrl: String, token: String): ApiResult<User> {
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<User> {
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
}
}
@@ -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<String>,
val snippet: String,
val attachments: List<Attachment> = emptyList(),
val reactions: List<Reaction> = 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,
)
@@ -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<List<Memo>> {
if (isCacheStale()) {
CoroutineScope(Dispatchers.IO).launch { refreshMemos() }
}
return memoDao.observeAll().map { entities -> entities.map { it.toDomain() } }
}
fun observeMemo(id: String): Flow<Memo?> =
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<List<Memo>> {
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<List<Memo>> {
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<Memo> {
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<Memo> {
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<Unit> {
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<Memo> {
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<Unit> {
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
}
}
@@ -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
}
}
@@ -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<String> = emptyList(),
val lists: List<String> = emptyList(),
)
@@ -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,
)