feat(notes): P1 слой данных — Notes API v1 клиент + репозиторий + ETag-кэш + модель
- NotesApiClient: публичный /api/v1 (list exclude=content + If-None-Match, get, create, saveContent с If-Match→412 NoteConflictException, setFavorite/Category/rename, delete) - NotesRepository: сессия→клиент→кэш, offline-first список (304/сеть → кэш) - NotesCache: дисковый ETag-кэш списка (образец CalendarEventsCache) - Note модель + разбор JSON; NoteConflictException (явный выбор, не молчаливая перезапись) - NotesApiClientTest: 7 юнит-тестов через MockWebServer (пишущее — юнитами, не forbion; mail/073) Верифицировано изолированно (worktree на чистом HEAD): compileDebugKotlin + 7/7 tests + detekt OK. Закрывает часть P1 notes.diff.md (слой данных). mail/096/093, план mail/071. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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