Compare commits
2 Commits
22bd16c455
...
8a8f1cfffb
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8f1cfffb | |||
| 6eeba1bba2 |
@@ -55,10 +55,12 @@ fun DeckCardSheet(
|
|||||||
stacks: List<DeckStack>,
|
stacks: List<DeckStack>,
|
||||||
canEdit: Boolean,
|
canEdit: Boolean,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
|
assignedToMe: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onToggleDone: () -> Unit,
|
onToggleDone: () -> Unit,
|
||||||
onSave: (title: String, description: String, duedate: String?) -> Unit,
|
onSave: (title: String, description: String, duedate: String?) -> Unit,
|
||||||
onToggleLabel: (DeckLabel) -> Unit,
|
onToggleLabel: (DeckLabel) -> Unit,
|
||||||
|
onToggleAssignSelf: () -> Unit,
|
||||||
onMove: (targetStackId: Int) -> Unit,
|
onMove: (targetStackId: Int) -> Unit,
|
||||||
onArchive: () -> Unit,
|
onArchive: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
@@ -146,6 +148,24 @@ fun DeckCardSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Исполнители
|
||||||
|
Text("Исполнители", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||||
|
if (card.assignees.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
card.assignees.joinToString(", "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canEdit) {
|
||||||
|
F7SecondaryButton(
|
||||||
|
text = if (assignedToMe) "Снять назначение с меня" else "Назначить мне",
|
||||||
|
onClick = onToggleAssignSelf,
|
||||||
|
enabled = !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Переместить в колонку
|
// Переместить в колонку
|
||||||
if (canEdit && stacks.size > 1) {
|
if (canEdit && stacks.size > 1) {
|
||||||
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class DeckRepository {
|
|||||||
stackId = stackId,
|
stackId = stackId,
|
||||||
labels = parseLabels(card.optJSONArray("labels")),
|
labels = parseLabels(card.optJSONArray("labels")),
|
||||||
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
|
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
|
||||||
|
assigneeUids = parseAssigneeUids(card.optJSONArray("assignedUsers")),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
|
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
|
||||||
@@ -126,6 +127,21 @@ class DeckRepository {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** uid'ы назначенных пользователей (type 0 — user) — для «назначен ли текущий пользователь». */
|
||||||
|
private fun parseAssigneeUids(array: JSONArray?): List<String> {
|
||||||
|
if (array == null) return emptyList()
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val a = array.optJSONObject(i)
|
||||||
|
if (a != null && a.optInt("type", 0) == 0) {
|
||||||
|
val uid = a.optJSONObject("participant")?.optString("uid")?.takeIf { it.isNotBlank() }
|
||||||
|
?: a.optString("participant").takeIf { it.isNotBlank() }
|
||||||
|
if (!uid.isNullOrBlank()) out += uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// --- Запись ---
|
// --- Запись ---
|
||||||
|
|
||||||
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
|
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
|
||||||
@@ -179,6 +195,18 @@ class DeckRepository {
|
|||||||
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
|
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Назначить пользователя (type 0) на карточку. */
|
||||||
|
fun assignUser(session: AuthSession, cardId: Int, uid: String) {
|
||||||
|
val body = JSONObject().put("userId", uid).put("type", 0)
|
||||||
|
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/assign", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снять назначение пользователя (type 0) с карточки. */
|
||||||
|
fun unassignUser(session: AuthSession, cardId: Int, uid: String) {
|
||||||
|
val body = JSONObject().put("userId", uid).put("type", 0)
|
||||||
|
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/unassign", body)
|
||||||
|
}
|
||||||
|
|
||||||
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
|
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
|
||||||
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
|
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
|
||||||
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
|
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
|
||||||
@@ -258,6 +286,7 @@ data class DeckCard(
|
|||||||
val stackId: Int = 0,
|
val stackId: Int = 0,
|
||||||
val labels: List<DeckLabel> = emptyList(),
|
val labels: List<DeckLabel> = emptyList(),
|
||||||
val assignees: List<String> = emptyList(),
|
val assignees: List<String> = emptyList(),
|
||||||
|
val assigneeUids: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DeckBoardDetail(
|
data class DeckBoardDetail(
|
||||||
|
|||||||
@@ -135,10 +135,12 @@ fun DeckScreen(
|
|||||||
stacks = state.boardDetail?.stacks.orEmpty(),
|
stacks = state.boardDetail?.stacks.orEmpty(),
|
||||||
canEdit = state.boardDetail?.canEdit ?: false,
|
canEdit = state.boardDetail?.canEdit ?: false,
|
||||||
busy = state.busy,
|
busy = state.busy,
|
||||||
|
assignedToMe = card.assigneeUids.contains(session.username),
|
||||||
onDismiss = vm::closeCard,
|
onDismiss = vm::closeCard,
|
||||||
onToggleDone = { vm.toggleDone(session, card) },
|
onToggleDone = { vm.toggleDone(session, card) },
|
||||||
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
|
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
|
||||||
onToggleLabel = { vm.toggleLabel(session, card, it) },
|
onToggleLabel = { vm.toggleLabel(session, card, it) },
|
||||||
|
onToggleAssignSelf = { vm.toggleAssignSelf(session, card) },
|
||||||
onMove = { vm.moveCard(session, card, it) },
|
onMove = { vm.moveCard(session, card, it) },
|
||||||
onArchive = { vm.archiveCard(session, card) },
|
onArchive = { vm.archiveCard(session, card) },
|
||||||
onDelete = { vm.deleteCard(session, card) },
|
onDelete = { vm.deleteCard(session, card) },
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ class DeckViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toggleAssignSelf(session: AuthSession, card: DeckCard) = mutate(session) {
|
||||||
|
if (card.assigneeUids.contains(session.username)) {
|
||||||
|
repository.unassignUser(session, card.id, session.username)
|
||||||
|
} else {
|
||||||
|
repository.assignUser(session, card.id, session.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createStack(session: AuthSession, title: String) {
|
fun createStack(session: AuthSession, title: String) {
|
||||||
val boardId = _state.value.selectedBoardId ?: return
|
val boardId = _state.value.selectedBoardId ?: return
|
||||||
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ dependencies {
|
|||||||
implementation libs.coil.compose
|
implementation libs.coil.compose
|
||||||
implementation libs.coil.svg
|
implementation libs.coil.svg
|
||||||
testImplementation libs.junit
|
testImplementation libs.junit
|
||||||
|
testImplementation libs.okhttp
|
||||||
|
testImplementation libs.okhttp.mockwebserver
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.notes
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заметка (Notes API v1). Поля контракта: id, etag, readonly, content, title, category,
|
||||||
|
* favorite, modified. Источник модели — `nextcloud/notes` v5.0.0 (карта app-map/notes.md).
|
||||||
|
*/
|
||||||
|
data class Note(
|
||||||
|
val id: Long,
|
||||||
|
val etag: String = "",
|
||||||
|
val readonly: Boolean = false,
|
||||||
|
val title: String = "",
|
||||||
|
val category: String = "",
|
||||||
|
val content: String = "",
|
||||||
|
val favorite: Boolean = false,
|
||||||
|
val modified: Long = 0L,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Разбор одной заметки из JSON ответа API v1. */
|
||||||
|
fun fromJson(obj: JSONObject): Note = Note(
|
||||||
|
id = obj.optLong("id", 0L),
|
||||||
|
etag = obj.optString("etag"),
|
||||||
|
readonly = obj.optBoolean("readonly", false),
|
||||||
|
title = obj.optString("title"),
|
||||||
|
category = obj.optString("category"),
|
||||||
|
content = obj.optString("content"),
|
||||||
|
favorite = obj.optBoolean("favorite", false),
|
||||||
|
modified = obj.optLong("modified", 0L),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Разбор списка заметок (GET /notes, в т.ч. с exclude=content). */
|
||||||
|
fun listFromJson(array: JSONArray): List<Note> {
|
||||||
|
val out = ArrayList<Note>(array.length())
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.optJSONObject(i)
|
||||||
|
if (obj != null && obj.optLong("id", 0L) > 0L) {
|
||||||
|
out += fromJson(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.notes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфликт версий заметки: сервер вернул 412 Precondition Failed на PUT с `If-Match`
|
||||||
|
* (заметка изменена в другой сессии). Разрешается ЯВНЫМ выбором пользователя (версия сервера
|
||||||
|
* или текущая), НЕ молчаливой перезаписью — согласовано с лидом (mail/071 п.2, диалог
|
||||||
|
* ConflictSolution в карте app-map/notes.md).
|
||||||
|
*
|
||||||
|
* @param serverNote версия с сервера (если её удалось перечитать) — для показа в диалоге.
|
||||||
|
*/
|
||||||
|
class NoteConflictException(
|
||||||
|
val noteId: Long,
|
||||||
|
val serverNote: Note? = null,
|
||||||
|
) : RuntimeException("Note $noteId changed on server (412)")
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.notes
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент публичного Notes API v1 (`/index.php/apps/notes/api/v1`). Стабильный контракт для
|
||||||
|
* сторонних клиентов (его же использует официальный Nextcloud Notes Android): ETag на
|
||||||
|
* коллекцию и заметку, `If-None-Match` для дешёвого рефреша, `exclude=content` для лёгкого
|
||||||
|
* списка, `If-Match` для конфликтов при записи (mail/071, карта app-map/notes.md).
|
||||||
|
*
|
||||||
|
* `client` инъектируется (в проде — [ru.forbion.f7cloud.core.network.NetworkFactory]
|
||||||
|
* .newAuthedClient), что делает клиент юнит-тестируемым через MockWebServer — пишущие
|
||||||
|
* сценарии проверяются юнитами, НЕ на боевом forbion (mail/073).
|
||||||
|
*/
|
||||||
|
class NotesApiClient(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
serverUrl: String,
|
||||||
|
) {
|
||||||
|
private val apiBase = serverUrl.trimEnd('/') + "/index.php/apps/notes/api/v1"
|
||||||
|
|
||||||
|
/** Результат листинга: заметки + ETag коллекции (для последующего If-None-Match). */
|
||||||
|
data class ListResult(val notes: List<Note>, val etag: String, val notModified: Boolean = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /notes — список. [excludeContent] → `exclude=content` (лёгкий список).
|
||||||
|
* [pruneBefore] → инкрементальный синк (только изменённые после метки). [ifNoneMatch] —
|
||||||
|
* ETag коллекции; при 304 возвращается [ListResult.notModified] = true с пустым списком.
|
||||||
|
*/
|
||||||
|
fun list(
|
||||||
|
excludeContent: Boolean = true,
|
||||||
|
pruneBefore: Long? = null,
|
||||||
|
ifNoneMatch: String? = null,
|
||||||
|
): ListResult {
|
||||||
|
val params = buildList {
|
||||||
|
if (excludeContent) add("exclude=content")
|
||||||
|
if (pruneBefore != null) add("pruneBefore=$pruneBefore")
|
||||||
|
}
|
||||||
|
val url = apiBase + "/notes" + if (params.isEmpty()) "" else "?" + params.joinToString("&")
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.apply { if (!ifNoneMatch.isNullOrBlank()) header("If-None-Match", ifNoneMatch) }
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (resp.code == HTTP_NOT_MODIFIED) {
|
||||||
|
return ListResult(emptyList(), ifNoneMatch.orEmpty(), notModified = true)
|
||||||
|
}
|
||||||
|
if (!resp.isSuccessful) error("Notes list HTTP ${resp.code}")
|
||||||
|
val body = resp.body?.string().orEmpty()
|
||||||
|
val notes = Note.listFromJson(JSONArray(if (body.isBlank()) "[]" else body))
|
||||||
|
return ListResult(notes, resp.header("ETag").orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /notes/{id} — одна заметка (с содержимым). */
|
||||||
|
fun get(id: Long): Note {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$apiBase/notes/$id")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) error("Notes get HTTP ${resp.code}")
|
||||||
|
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /notes — создать заметку. */
|
||||||
|
fun create(content: String, category: String = ""): Note {
|
||||||
|
val payload = JSONObject().put("content", content)
|
||||||
|
if (category.isNotEmpty()) payload.put("category", category)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$apiBase/notes")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.post(payload.toString().toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) error("Notes create HTTP ${resp.code}")
|
||||||
|
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /notes/{id} с телом `content` и заголовком `If-Match: "<etag>"`. На 412 — конфликт
|
||||||
|
* версий: перечитываем серверную версию и бросаем [NoteConflictException] (НЕ молчаливо
|
||||||
|
* перезаписываем — mail/071 п.2).
|
||||||
|
*/
|
||||||
|
fun saveContent(note: Note): Note {
|
||||||
|
val payload = JSONObject().put("content", note.content)
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url("$apiBase/notes/${note.id}")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.put(payload.toString().toRequestBody(JSON))
|
||||||
|
if (note.etag.isNotBlank()) builder.header("If-Match", "\"${note.etag}\"")
|
||||||
|
client.newCall(builder.build()).execute().use { resp ->
|
||||||
|
if (resp.code == HTTP_PRECONDITION_FAILED) {
|
||||||
|
val server = runCatching { get(note.id) }.getOrNull()
|
||||||
|
throw NoteConflictException(note.id, server)
|
||||||
|
}
|
||||||
|
if (!resp.isSuccessful) error("Notes save HTTP ${resp.code}")
|
||||||
|
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /notes/{id} — обновить метаданные (favorite/category/title) без If-Match. */
|
||||||
|
private fun patchMeta(id: Long, field: String, value: Any): Note {
|
||||||
|
val payload = JSONObject().put(field, value)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$apiBase/notes/$id")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.put(payload.toString().toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) error("Notes patch HTTP ${resp.code}")
|
||||||
|
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(id: Long, favorite: Boolean): Note = patchMeta(id, "favorite", favorite)
|
||||||
|
|
||||||
|
fun setCategory(id: Long, category: String): Note = patchMeta(id, "category", category)
|
||||||
|
|
||||||
|
fun rename(id: Long, title: String): Note = patchMeta(id, "title", title)
|
||||||
|
|
||||||
|
/** DELETE /notes/{id}. */
|
||||||
|
fun delete(id: Long) {
|
||||||
|
val req = Request.Builder().url("$apiBase/notes/$id").delete().build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) error("Notes delete HTTP ${resp.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val JSON = "application/json; charset=utf-8".toMediaType()
|
||||||
|
const val HTTP_NOT_MODIFIED = 304
|
||||||
|
const val HTTP_PRECONDITION_FAILED = 412
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.notes
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дисковый кэш списка заметок, ключуемый ETag коллекции (образец — CalendarEventsCache).
|
||||||
|
* Формат файла: первая строка — ETag на момент загрузки, дальше сырой JSON массива заметок
|
||||||
|
* (переиспользуем боевой парсер [Note.listFromJson], без хрупкого промежуточного маппинга).
|
||||||
|
*
|
||||||
|
* Даёт: (1) offline-отдачу списка при отсутствии сети; (2) быстрый рефреш через
|
||||||
|
* `If-None-Match` — при 304 сеть не качает тело, читаем диск.
|
||||||
|
*/
|
||||||
|
class NotesCache(private val dir: File) {
|
||||||
|
|
||||||
|
constructor(context: Context) : this(File(context.cacheDir, "notes_list"))
|
||||||
|
|
||||||
|
data class Entry(val etag: String, val json: String)
|
||||||
|
|
||||||
|
/** Кэш списка для аккаунта, если есть; иначе null. */
|
||||||
|
fun get(accountKey: String): Entry? {
|
||||||
|
val f = entryFile(accountKey)
|
||||||
|
if (!f.isFile) return null
|
||||||
|
return runCatching {
|
||||||
|
val text = f.readText()
|
||||||
|
val nl = text.indexOf('\n')
|
||||||
|
if (nl <= 0) null else Entry(text.substring(0, nl), text.substring(nl + 1))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(accountKey: String, etag: String, json: String) {
|
||||||
|
if (etag.isBlank()) return
|
||||||
|
runCatching {
|
||||||
|
dir.mkdirs()
|
||||||
|
entryFile(accountKey).writeText("$etag\n$json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(accountKey: String) {
|
||||||
|
runCatching { entryFile(accountKey).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryFile(accountKey: String): File = File(dir, md5(accountKey) + ".json")
|
||||||
|
|
||||||
|
private fun md5(s: String): String =
|
||||||
|
MessageDigest.getInstance("MD5").digest(s.toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.notes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий Заметок: связывает сессию ([AuthSession]) → [NotesApiClient] → [NotesCache].
|
||||||
|
* Сеть — на IO. Списки отдаются из ETag-кэша при 304 или отсутствии сети (offline-first
|
||||||
|
* read). Пишущие операции идут напрямую в API (проверяются юнит-тестами, не на forbion —
|
||||||
|
* mail/073).
|
||||||
|
*/
|
||||||
|
class NotesRepository(
|
||||||
|
private val cache: NotesCache? = null,
|
||||||
|
private val apiFactory: (AuthSession) -> NotesApiClient = ::defaultApi,
|
||||||
|
) {
|
||||||
|
private fun accountKey(s: AuthSession) = "${s.serverUrl}|${s.davUserId ?: s.username}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список заметок (лёгкий, exclude=content). Пытается сеть с `If-None-Match` по кэш-ETag;
|
||||||
|
* при 304 или сетевой ошибке возвращает кэш (если он есть). Свежий ответ кладётся в кэш.
|
||||||
|
*/
|
||||||
|
suspend fun listNotes(session: AuthSession): List<Note> = withContext(Dispatchers.IO) {
|
||||||
|
val api = apiFactory(session)
|
||||||
|
val key = accountKey(session)
|
||||||
|
val cached = cache?.get(key)
|
||||||
|
try {
|
||||||
|
val res = api.list(excludeContent = true, ifNoneMatch = cached?.etag)
|
||||||
|
if (res.notModified) {
|
||||||
|
cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) }
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
cache?.put(key, res.etag, notesToJson(res.notes))
|
||||||
|
res.notes
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// offline / сеть недоступна → отдаём кэш, если он есть
|
||||||
|
cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNote(session: AuthSession, id: Long): Note = withContext(Dispatchers.IO) {
|
||||||
|
apiFactory(session).get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createNote(session: AuthSession, content: String, category: String = ""): Note =
|
||||||
|
withContext(Dispatchers.IO) { apiFactory(session).create(content, category) }
|
||||||
|
|
||||||
|
/** Сохранение содержимого с If-Match; [NoteConflictException] на 412 (обрабатывает UI). */
|
||||||
|
suspend fun saveNote(session: AuthSession, note: Note): Note = withContext(Dispatchers.IO) {
|
||||||
|
apiFactory(session).saveContent(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setFavorite(session: AuthSession, id: Long, favorite: Boolean): Note =
|
||||||
|
withContext(Dispatchers.IO) { apiFactory(session).setFavorite(id, favorite) }
|
||||||
|
|
||||||
|
suspend fun setCategory(session: AuthSession, id: Long, category: String): Note =
|
||||||
|
withContext(Dispatchers.IO) { apiFactory(session).setCategory(id, category) }
|
||||||
|
|
||||||
|
suspend fun renameNote(session: AuthSession, id: Long, title: String): Note =
|
||||||
|
withContext(Dispatchers.IO) { apiFactory(session).rename(id, title) }
|
||||||
|
|
||||||
|
suspend fun deleteNote(session: AuthSession, id: Long) = withContext(Dispatchers.IO) {
|
||||||
|
apiFactory(session).delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notesToJson(notes: List<Note>): String {
|
||||||
|
val arr = JSONArray()
|
||||||
|
notes.forEach { n ->
|
||||||
|
arr.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("id", n.id)
|
||||||
|
.put("etag", n.etag)
|
||||||
|
.put("readonly", n.readonly)
|
||||||
|
.put("title", n.title)
|
||||||
|
.put("category", n.category)
|
||||||
|
.put("content", n.content)
|
||||||
|
.put("favorite", n.favorite)
|
||||||
|
.put("modified", n.modified),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return arr.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun defaultApi(session: AuthSession): NotesApiClient {
|
||||||
|
val client = NetworkFactory.newAuthedClient(
|
||||||
|
username = session.username,
|
||||||
|
appPassword = session.appPassword,
|
||||||
|
trustAllCerts = session.trustAllCerts,
|
||||||
|
)
|
||||||
|
return NotesApiClient(client, session.serverUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.notes
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Юнит-тесты Notes API v1 через MockWebServer — пишущие сценарии (create/save/If-Match/412)
|
||||||
|
* проверяются здесь, НЕ на боевом forbion (mail/073). Покрывают контракт из карты
|
||||||
|
* app-map/notes.md: exclude=content, ETag/If-None-Match, If-Match→412 конфликт.
|
||||||
|
*/
|
||||||
|
class NotesApiClientTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var api: NotesApiClient
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
api = NotesApiClient(OkHttpClient(), server.url("/").toString().trimEnd('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list parses notes and sends exclude=content plus ETag`() {
|
||||||
|
val body = """[{"id":7,"etag":"e7","title":"Заметка",""" +
|
||||||
|
""""category":"Работа","favorite":true,"modified":100}]"""
|
||||||
|
server.enqueue(MockResponse().setHeader("ETag", "coll-1").setBody(body))
|
||||||
|
|
||||||
|
val res = api.list(excludeContent = true, ifNoneMatch = "prev-etag")
|
||||||
|
|
||||||
|
assertFalse(res.notModified)
|
||||||
|
assertEquals("coll-1", res.etag)
|
||||||
|
assertEquals(1, res.notes.size)
|
||||||
|
assertEquals(7L, res.notes[0].id)
|
||||||
|
assertEquals("Заметка", res.notes[0].title)
|
||||||
|
assertTrue(res.notes[0].favorite)
|
||||||
|
|
||||||
|
val recorded = server.takeRequest()
|
||||||
|
assertEquals("/index.php/apps/notes/api/v1/notes?exclude=content", recorded.path)
|
||||||
|
assertEquals("prev-etag", recorded.getHeader("If-None-Match"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list returns notModified on 304`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(304))
|
||||||
|
|
||||||
|
val res = api.list(ifNoneMatch = "coll-1")
|
||||||
|
|
||||||
|
assertTrue(res.notModified)
|
||||||
|
assertTrue(res.notes.isEmpty())
|
||||||
|
assertEquals("coll-1", res.etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveContent sends If-Match with quoted etag`() {
|
||||||
|
server.enqueue(MockResponse().setBody("""{"id":7,"etag":"e8","content":"new"}"""))
|
||||||
|
|
||||||
|
val saved = api.saveContent(Note(id = 7, etag = "e7", content = "new"))
|
||||||
|
|
||||||
|
assertEquals("e8", saved.etag)
|
||||||
|
val recorded = server.takeRequest()
|
||||||
|
assertEquals("PUT", recorded.method)
|
||||||
|
assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path)
|
||||||
|
assertEquals("\"e7\"", recorded.getHeader("If-Match"))
|
||||||
|
assertTrue(recorded.body.readUtf8().contains("\"content\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveContent on 412 rereads server version and throws conflict`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(412)) // PUT → конфликт
|
||||||
|
server.enqueue(MockResponse().setBody("""{"id":7,"etag":"server","content":"srv"}""")) // GET перечит
|
||||||
|
|
||||||
|
val ex = assertThrows(NoteConflictException::class.java) {
|
||||||
|
api.saveContent(Note(id = 7, etag = "stale", content = "mine"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(7L, ex.noteId)
|
||||||
|
assertEquals("server", ex.serverNote?.etag)
|
||||||
|
assertEquals("srv", ex.serverNote?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create posts content and category`() {
|
||||||
|
server.enqueue(MockResponse().setBody("""{"id":9,"etag":"e9","content":"c","category":"Личное"}"""))
|
||||||
|
|
||||||
|
val note = api.create(content = "c", category = "Личное")
|
||||||
|
|
||||||
|
assertEquals(9L, note.id)
|
||||||
|
val recorded = server.takeRequest()
|
||||||
|
assertEquals("POST", recorded.method)
|
||||||
|
assertEquals("/index.php/apps/notes/api/v1/notes", recorded.path)
|
||||||
|
val body = recorded.body.readUtf8()
|
||||||
|
assertTrue(body.contains("\"content\""))
|
||||||
|
assertTrue(body.contains("Личное"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setFavorite puts favorite field without If-Match`() {
|
||||||
|
server.enqueue(MockResponse().setBody("""{"id":7,"favorite":true}"""))
|
||||||
|
|
||||||
|
val note = api.setFavorite(7, true)
|
||||||
|
|
||||||
|
assertTrue(note.favorite)
|
||||||
|
val recorded = server.takeRequest()
|
||||||
|
assertEquals("PUT", recorded.method)
|
||||||
|
assertNull(recorded.getHeader("If-Match"))
|
||||||
|
assertTrue(recorded.body.readUtf8().contains("\"favorite\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete issues DELETE on note id`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
|
||||||
|
api.delete(7)
|
||||||
|
|
||||||
|
val recorded = server.takeRequest()
|
||||||
|
assertEquals("DELETE", recorded.method)
|
||||||
|
assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user