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:
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user