feat(mail): фильтры — на официальный API, ОБЩИЕ с вебом, полное управление
Заменил собственный sieve-слой (маркированные блоки F7MOBILE) на
штатный API NC Mail GET/PUT /apps/mail/api/filter/{accountId} — тот
самый, которым пользуется веб-модалка «Новый фильтр». Теперь:
- видны ВСЕ правила (созданные и в вебе, и в приложении);
- любое правило можно включить/выключить и удалить с телефона;
- простые правила (1 условие) редактируются инлайн с префиллом формы;
сложные помечаются «правится в вебе», их JSON сохраняется целиком
(MailFilterRule держит raw и не теряет незнакомые поля);
- сериализацию в sieve делает сервер (FilterBuilder, секция
«### Nextcloud Mail: Filters ###») — совместимость гарантирована.
Действия конструктора: переместить в папку / пометить прочитанным
(addsystemflag \\Seen). MailFilterRuleTest — 3 юнит-теста зелёные.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -967,29 +967,34 @@ class MailRepository {
|
|||||||
}.sortedByDescending { it.updatedAt }
|
}.sortedByDescending { it.updatedAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sieve-фильтры (паритет живого веба, модалка «Новый фильтр») ---
|
// --- Фильтры почты: официальный API NC Mail (тот же, что у веб-модалки «Новый фильтр») ---
|
||||||
|
// GET/PUT /apps/mail/api/filter/{accountId}; сервер сам сериализует в sieve-скрипт
|
||||||
|
// (секция «### Nextcloud Mail: Filters ###»), правила ОБЩИЕ между вебом и приложением.
|
||||||
|
|
||||||
fun getSieveScript(session: AuthSession, accountId: Int): String {
|
fun getMailFilters(session: AuthSession, accountId: Int): List<MailFilterRule> {
|
||||||
val client = authedClient(session)
|
val client = authedClient(session)
|
||||||
val json = getJson(client, "${apiBase(session)}/sieve/active/$accountId")
|
val array = getJsonArray(client, "${apiBase(session)}/filter/$accountId")
|
||||||
return (json as? JSONObject)?.optString("script").orEmpty()
|
return buildList {
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
array.optJSONObject(i)?.let { add(MailFilterRule(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putSieveScript(session: AuthSession, accountId: Int, script: String) {
|
fun putMailFilters(session: AuthSession, accountId: Int, filters: List<MailFilterRule>) {
|
||||||
val client = authedClient(session)
|
val client = authedClient(session)
|
||||||
val payload = JSONObject().put("script", script).toString()
|
val payload = JSONObject()
|
||||||
|
.put("filters", JSONArray().apply { filters.forEach { put(it.raw) } })
|
||||||
|
.toString()
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("${apiBase(session)}/sieve/active/$accountId")
|
.url("${apiBase(session)}/filter/$accountId")
|
||||||
.header("OCS-APIRequest", "true")
|
.header("OCS-APIRequest", "true")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.put(payload.toRequestBody("application/json".toMediaType()))
|
.put(payload.toRequestBody("application/json".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val message = response.body?.string()
|
error("Filter API HTTP ${response.code}")
|
||||||
?.let { runCatching { JSONObject(it).optString("message") }.getOrNull() }
|
|
||||||
.orEmpty()
|
|
||||||
error("Sieve HTTP ${response.code}${if (message.isNotBlank()) ": $message" else ""}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -999,76 +1004,78 @@ class MailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Правило фильтра, которым управляет приложение (наш маркированный блок в sieve-скрипте). */
|
|
||||||
data class MailSieveRule(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
/** from | subject | to */
|
|
||||||
val field: String,
|
|
||||||
val contains: String,
|
|
||||||
/** fileinto | markread | discard */
|
|
||||||
val action: String,
|
|
||||||
val targetFolder: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Наши правила живут в блоках `# F7MOBILE-BEGIN {json}` … `# F7MOBILE-END`.
|
* Правило фильтра почты. Держим исходный JSON (raw) целиком, чтобы при правке
|
||||||
* Остальной текст скрипта (правила из веба/пользовательские) не трогаем.
|
* простых полей не терять незнакомые поля сложных правил, созданных в вебе.
|
||||||
*/
|
*/
|
||||||
object MailSieveScript {
|
class MailFilterRule(val raw: JSONObject) {
|
||||||
private const val REQUIRE_MARK = "# F7MOBILE-REQUIRE"
|
val name: String get() = raw.optString("name")
|
||||||
private const val BEGIN = "# F7MOBILE-BEGIN "
|
val enable: Boolean get() = raw.optBoolean("enable", true)
|
||||||
private const val END = "# F7MOBILE-END"
|
val operator: String get() = raw.optString("operator", "allof")
|
||||||
private val blockPattern = Regex(
|
val priority: Int get() = raw.optInt("priority", 10)
|
||||||
"^${Regex.escape(BEGIN)}(\\{.*?\\})\\n.*?^${Regex.escape(END)}\\n?",
|
|
||||||
setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun parseRules(script: String): List<MailSieveRule> =
|
val tests: List<Triple<String, String, List<String>>>
|
||||||
blockPattern.findAll(script).mapNotNull { match ->
|
get() = raw.optJSONArray("tests")?.let { arr ->
|
||||||
runCatching {
|
buildList {
|
||||||
val o = JSONObject(match.groupValues[1])
|
for (i in 0 until arr.length()) {
|
||||||
MailSieveRule(
|
val t = arr.optJSONObject(i) ?: continue
|
||||||
id = o.getString("id"),
|
val values = t.optJSONArray("values")?.let { v ->
|
||||||
name = o.optString("name"),
|
(0 until v.length()).map { v.optString(it) }
|
||||||
field = o.optString("field", "subject"),
|
}.orEmpty()
|
||||||
contains = o.optString("contains"),
|
add(Triple(t.optString("field"), t.optString("operator", "contains"), values))
|
||||||
action = o.optString("action", "fileinto"),
|
}
|
||||||
targetFolder = o.optString("target"),
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
}.toList()
|
|
||||||
|
|
||||||
fun renderScript(existing: String, rules: List<MailSieveRule>): String {
|
|
||||||
// Снимаем наши старые блоки и require-заголовок; пользовательский текст сохраняем.
|
|
||||||
val withoutBlocks = blockPattern.replace(existing, "")
|
|
||||||
val user = withoutBlocks.lineSequence()
|
|
||||||
.filterNot { it.trim() == REQUIRE_MARK }
|
|
||||||
.filterNot { it.trim() == "require [\"fileinto\", \"imap4flags\"];" }
|
|
||||||
.joinToString("\n")
|
|
||||||
.trim('\n')
|
|
||||||
if (rules.isEmpty()) return if (user.isBlank()) "" else user + "\n"
|
|
||||||
val header = "$REQUIRE_MARK\nrequire [\"fileinto\", \"imap4flags\"];\n"
|
|
||||||
val blocks = rules.joinToString("\n") { rule ->
|
|
||||||
val meta = JSONObject()
|
|
||||||
.put("id", rule.id)
|
|
||||||
.put("name", rule.name)
|
|
||||||
.put("field", rule.field)
|
|
||||||
.put("contains", rule.contains)
|
|
||||||
.put("action", rule.action)
|
|
||||||
.put("target", rule.targetFolder)
|
|
||||||
val condition = "header :contains \"${sieveEscape(rule.field)}\" \"${sieveEscape(rule.contains)}\""
|
|
||||||
val body = when (rule.action) {
|
|
||||||
"markread" -> " addflag \"\\\\Seen\";"
|
|
||||||
"discard" -> " discard;"
|
|
||||||
else -> " fileinto \"${sieveEscape(rule.targetFolder)}\";"
|
|
||||||
}
|
}
|
||||||
"$BEGIN$meta\nif $condition {\n$body\n}\n$END"
|
}.orEmpty()
|
||||||
}
|
|
||||||
// require обязан быть в начале скрипта — наш заголовок идёт первым.
|
|
||||||
return listOf(header + blocks, user).filter { it.isNotBlank() }.joinToString("\n\n") + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sieveEscape(value: String): String =
|
val actions: List<JSONObject>
|
||||||
value.replace("\\", "\\\\").replace("\"", "\\\"")
|
get() = raw.optJSONArray("actions")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { arr.optJSONObject(it) }
|
||||||
|
}.orEmpty()
|
||||||
|
|
||||||
|
/** Простое правило (1 тест, действия из нашего конструктора) — редактируемо инлайн. */
|
||||||
|
val isSimple: Boolean
|
||||||
|
get() = tests.size == 1 && actions.all {
|
||||||
|
it.optString("type") in setOf("fileinto", "addflag", "addsystemflag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withEnable(value: Boolean): MailFilterRule =
|
||||||
|
MailFilterRule(JSONObject(raw.toString()).put("enable", value))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun simple(
|
||||||
|
name: String,
|
||||||
|
field: String,
|
||||||
|
contains: String,
|
||||||
|
action: String,
|
||||||
|
targetFolder: String,
|
||||||
|
priority: Int = 10,
|
||||||
|
): MailFilterRule {
|
||||||
|
val actions = JSONArray()
|
||||||
|
when (action) {
|
||||||
|
"markread" -> actions.put(
|
||||||
|
JSONObject().put("type", "addsystemflag").put("flag", "\\Seen"),
|
||||||
|
)
|
||||||
|
else -> actions.put(
|
||||||
|
JSONObject().put("type", "fileinto").put("mailbox", targetFolder),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return MailFilterRule(
|
||||||
|
JSONObject()
|
||||||
|
.put("name", name)
|
||||||
|
.put("enable", true)
|
||||||
|
.put("operator", "allof")
|
||||||
|
.put("priority", priority)
|
||||||
|
.put(
|
||||||
|
"tests",
|
||||||
|
JSONArray().put(
|
||||||
|
JSONObject()
|
||||||
|
.put("field", field)
|
||||||
|
.put("operator", "contains")
|
||||||
|
.put("values", JSONArray().put(contains)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.put("actions", actions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,8 +262,10 @@ fun MailScreen(
|
|||||||
saving = state.sieveSaving,
|
saving = state.sieveSaving,
|
||||||
error = state.sieveError,
|
error = state.sieveError,
|
||||||
onDismiss = { vm.closeSieveFilters() },
|
onDismiss = { vm.closeSieveFilters() },
|
||||||
onAdd = { rule -> vm.addSieveRule(session, rule) },
|
onAdd = { rule -> vm.addFilterRule(session, rule) },
|
||||||
onDelete = { id -> vm.deleteSieveRule(session, id) },
|
onUpdate = { index, rule -> vm.updateFilterRule(session, index, rule) },
|
||||||
|
onDelete = { index -> vm.deleteFilterRule(session, index) },
|
||||||
|
onToggle = { index, enable -> vm.toggleFilterRule(session, index, enable) },
|
||||||
)
|
)
|
||||||
|
|
||||||
BackHandler(enabled = mailCanGoBack) {
|
BackHandler(enabled = mailCanGoBack) {
|
||||||
|
|||||||
+95
-44
@@ -19,9 +19,12 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -40,53 +43,65 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
|
|||||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому")
|
private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому")
|
||||||
private val ACTION_OPTIONS = listOf(
|
private val ACTION_OPTIONS = listOf(
|
||||||
"fileinto" to "Переместить в папку",
|
"fileinto" to "Переместить в папку",
|
||||||
"markread" to "Пометить прочитанным",
|
"markread" to "Пометить прочитанным",
|
||||||
"discard" to "Удалить",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Пилюля-переключатель варианта (как чипы живой темы: 25dp, актив — #ECF9DE). */
|
/** Пилюля-переключатель варианта (чипы живой темы: актив — #ECF9DE). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) {
|
private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.heightIn(min = 25.dp)
|
.heightIn(min = 25.dp)
|
||||||
.clip(RoundedCornerShape(100.dp))
|
.clip(RoundedCornerShape(100.dp))
|
||||||
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Background)
|
.background(if (selected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
|
||||||
.then(
|
|
||||||
Modifier.padding(0.dp),
|
|
||||||
)
|
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||||
text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = F7Colors.TextPrimary,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ruleSummary(rule: MailFilterRule): String {
|
||||||
|
val tests = rule.tests.joinToString("; ") { (field, op, values) ->
|
||||||
|
val fieldLabel = FIELD_OPTIONS.firstOrNull { it.first == field }?.second ?: field
|
||||||
|
"$fieldLabel $op «${values.joinToString(", ")}»"
|
||||||
|
}
|
||||||
|
val actions = rule.actions.joinToString("; ") { action ->
|
||||||
|
when (action.optString("type")) {
|
||||||
|
"fileinto" -> "в папку «${action.optString("mailbox")}»"
|
||||||
|
"addflag", "addsystemflag" ->
|
||||||
|
if (action.optString("flag").contains("Seen")) "пометить прочитанным"
|
||||||
|
else "флаг «${action.optString("flag")}»"
|
||||||
|
"keep" -> "оставить"
|
||||||
|
"stop" -> "стоп"
|
||||||
|
else -> action.optString("type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "$tests → $actions"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Модалка «Фильтры» (sieve) — паритет живого веба: список правил приложения
|
* Фильтры почты — общие с веб-версией (официальный API NC Mail).
|
||||||
* + конструктор «Новый фильтр» (поле содержит значение → действие).
|
* Список всех правил (вкл/выкл, удаление), редактирование простых, конструктор нового.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MailSieveFiltersSheet(
|
fun MailSieveFiltersSheet(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
rules: List<MailSieveRule>,
|
rules: List<MailFilterRule>,
|
||||||
folders: List<String>,
|
folders: List<String>,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (MailSieveRule) -> Unit,
|
onAdd: (MailFilterRule) -> Unit,
|
||||||
onDelete: (String) -> Unit,
|
onUpdate: (Int, MailFilterRule) -> Unit,
|
||||||
|
onDelete: (Int) -> Unit,
|
||||||
|
onToggle: (Int, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
var name by rememberSaveable { mutableStateOf("") }
|
var name by rememberSaveable { mutableStateOf("") }
|
||||||
@@ -94,6 +109,27 @@ fun MailSieveFiltersSheet(
|
|||||||
var contains by rememberSaveable { mutableStateOf("") }
|
var contains by rememberSaveable { mutableStateOf("") }
|
||||||
var action by rememberSaveable { mutableStateOf("fileinto") }
|
var action by rememberSaveable { mutableStateOf("fileinto") }
|
||||||
var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) }
|
var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) }
|
||||||
|
// -1 = создание нового; иначе — индекс редактируемого правила
|
||||||
|
var editingIndex by rememberSaveable { mutableIntStateOf(-1) }
|
||||||
|
|
||||||
|
fun loadIntoForm(rule: MailFilterRule) {
|
||||||
|
name = rule.name
|
||||||
|
rule.tests.firstOrNull()?.let { (f, _, values) ->
|
||||||
|
field = f
|
||||||
|
contains = values.joinToString(", ")
|
||||||
|
}
|
||||||
|
val act = rule.actions.firstOrNull()
|
||||||
|
action = if (act?.optString("type") == "fileinto") "fileinto" else "markread"
|
||||||
|
if (act?.optString("type") == "fileinto") {
|
||||||
|
targetFolder = act.optString("mailbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearForm() {
|
||||||
|
name = ""
|
||||||
|
contains = ""
|
||||||
|
editingIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -102,7 +138,7 @@ fun MailSieveFiltersSheet(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.94f)
|
.fillMaxWidth(0.94f)
|
||||||
.heightIn(max = 640.dp),
|
.heightIn(max = 660.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
) {
|
) {
|
||||||
@@ -128,19 +164,25 @@ fun MailSieveFiltersSheet(
|
|||||||
else -> {
|
else -> {
|
||||||
if (rules.isEmpty()) {
|
if (rules.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"Фильтров из приложения пока нет. Правила, созданные в веб-версии, не показываются и не изменяются.",
|
"Фильтров пока нет.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = F7Colors.TextSecondary,
|
color = F7Colors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
rules.forEach { rule ->
|
rules.forEachIndexed { index, rule ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 36.dp)
|
.heightIn(min = 36.dp)
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(F7Colors.SurfaceMuted)
|
.background(
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
if (editingIndex == index) F7Colors.PrimaryLight else F7Colors.SurfaceMuted,
|
||||||
|
)
|
||||||
|
.clickable(enabled = !saving && rule.isSimple) {
|
||||||
|
editingIndex = index
|
||||||
|
loadIntoForm(rule)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -153,32 +195,36 @@ fun MailSieveFiltersSheet(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val fieldLabel = FIELD_OPTIONS.firstOrNull { it.first == rule.field }?.second ?: rule.field
|
|
||||||
val actionLabel = when (rule.action) {
|
|
||||||
"markread" -> "пометить прочитанным"
|
|
||||||
"discard" -> "удалить"
|
|
||||||
else -> "в папку «${rule.targetFolder}»"
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
"$fieldLabel содержит «${rule.contains}» → $actionLabel",
|
ruleSummary(rule) +
|
||||||
|
if (!rule.isSimple) " (сложное — правится в вебе)" else "",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = F7Colors.TextSecondary,
|
color = F7Colors.TextSecondary,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Switch(
|
||||||
|
checked = rule.enable,
|
||||||
|
onCheckedChange = { onToggle(index, it) },
|
||||||
|
enabled = !saving,
|
||||||
|
colors = SwitchDefaults.colors(checkedTrackColor = F7Colors.Primary),
|
||||||
|
)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Close,
|
imageVector = Icons.Filled.Close,
|
||||||
contentDescription = "Удалить фильтр",
|
contentDescription = "Удалить фильтр",
|
||||||
tint = F7Colors.TextSecondary,
|
tint = F7Colors.TextSecondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.clickable(enabled = !saving) { onDelete(rule.id) },
|
.clickable(enabled = !saving) {
|
||||||
|
if (editingIndex == index) clearForm()
|
||||||
|
onDelete(index)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Новый фильтр",
|
if (editingIndex >= 0) "Изменить фильтр" else "Новый фильтр",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = F7Colors.TextPrimary,
|
color = F7Colors.TextPrimary,
|
||||||
@@ -225,22 +271,27 @@ fun MailSieveFiltersSheet(
|
|||||||
Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f))
|
F7SecondaryButton(
|
||||||
|
if (editingIndex >= 0) "Отменить правку" else "Закрыть",
|
||||||
|
onClick = { if (editingIndex >= 0) clearForm() else onDismiss() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
F7PrimaryButton(
|
F7PrimaryButton(
|
||||||
text = if (saving) "Сохранение…" else "Создать",
|
text = when {
|
||||||
|
saving -> "Сохранение…"
|
||||||
|
editingIndex >= 0 -> "Сохранить"
|
||||||
|
else -> "Создать"
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onAdd(
|
val rule = MailFilterRule.simple(
|
||||||
MailSieveRule(
|
name = name.trim().ifBlank { "Фильтр" },
|
||||||
id = UUID.randomUUID().toString().take(8),
|
field = field,
|
||||||
name = name.trim().ifBlank { "Фильтр" },
|
contains = contains.trim(),
|
||||||
field = field,
|
action = action,
|
||||||
contains = contains.trim(),
|
targetFolder = if (action == "fileinto") targetFolder else "",
|
||||||
action = action,
|
|
||||||
targetFolder = if (action == "fileinto") targetFolder else "",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
name = ""
|
if (editingIndex >= 0) onUpdate(editingIndex, rule) else onAdd(rule)
|
||||||
contains = ""
|
clearForm()
|
||||||
},
|
},
|
||||||
enabled = !saving && contains.isNotBlank() &&
|
enabled = !saving && contains.isNotBlank() &&
|
||||||
(action != "fileinto" || targetFolder.isNotBlank()),
|
(action != "fileinto" || targetFolder.isNotBlank()),
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ data class MailUiState(
|
|||||||
val createEventSheetOpen: Boolean = false,
|
val createEventSheetOpen: Boolean = false,
|
||||||
val createTargets: List<MailTargetList> = emptyList(),
|
val createTargets: List<MailTargetList> = emptyList(),
|
||||||
val creatingFromMessage: Boolean = false,
|
val creatingFromMessage: Boolean = false,
|
||||||
// Sieve-фильтры (паритет живого веба)
|
// Фильтры почты (официальный API NC Mail — общие с вебом)
|
||||||
val sieveAccountId: Int? = null,
|
val sieveAccountId: Int? = null,
|
||||||
val sieveRules: List<MailSieveRule> = emptyList(),
|
val sieveRules: List<MailFilterRule> = emptyList(),
|
||||||
val sieveLoading: Boolean = false,
|
val sieveLoading: Boolean = false,
|
||||||
val sieveSaving: Boolean = false,
|
val sieveSaving: Boolean = false,
|
||||||
val sieveError: String? = null,
|
val sieveError: String? = null,
|
||||||
@@ -1105,7 +1105,7 @@ class MailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sieve-фильтры ---
|
// --- Фильтры почты (общие с вебом, API /api/filter/{accountId}) ---
|
||||||
|
|
||||||
fun openSieveFilters(session: AuthSession, accountId: Int) {
|
fun openSieveFilters(session: AuthSession, accountId: Int) {
|
||||||
_state.update {
|
_state.update {
|
||||||
@@ -1113,12 +1113,12 @@ class MailViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
MailSieveScript.parseRules(repository.getSieveScript(session, accountId))
|
repository.getMailFilters(session, accountId)
|
||||||
}.onSuccess { rules ->
|
}.onSuccess { rules ->
|
||||||
_state.update { it.copy(sieveRules = rules, sieveLoading = false) }
|
_state.update { it.copy(sieveRules = rules, sieveLoading = false) }
|
||||||
}.onFailure { t ->
|
}.onFailure { t ->
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(sieveLoading = false, sieveError = t.message ?: "Sieve недоступен")
|
it.copy(sieveLoading = false, sieveError = t.message ?: "Фильтры недоступны")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1128,29 +1128,45 @@ class MailViewModel(
|
|||||||
_state.update { it.copy(sieveAccountId = null, sieveError = null) }
|
_state.update { it.copy(sieveAccountId = null, sieveError = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSieveRule(session: AuthSession, rule: MailSieveRule) {
|
fun addFilterRule(session: AuthSession, rule: MailFilterRule) {
|
||||||
saveSieveRules(session) { it + rule }
|
saveFilterRules(session) { it + rule }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSieveRule(session: AuthSession, ruleId: String) {
|
fun updateFilterRule(session: AuthSession, index: Int, rule: MailFilterRule) {
|
||||||
saveSieveRules(session) { rules -> rules.filterNot { it.id == ruleId } }
|
saveFilterRules(session) { rules ->
|
||||||
|
rules.toMutableList().also { if (index in it.indices) it[index] = rule }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveSieveRules(
|
fun deleteFilterRule(session: AuthSession, index: Int) {
|
||||||
|
saveFilterRules(session) { rules ->
|
||||||
|
rules.toMutableList().also { if (index in it.indices) it.removeAt(index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFilterRule(session: AuthSession, index: Int, enable: Boolean) {
|
||||||
|
saveFilterRules(session) { rules ->
|
||||||
|
rules.toMutableList().also {
|
||||||
|
if (index in it.indices) it[index] = it[index].withEnable(enable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFilterRules(
|
||||||
session: AuthSession,
|
session: AuthSession,
|
||||||
transform: (List<MailSieveRule>) -> List<MailSieveRule>,
|
transform: (List<MailFilterRule>) -> List<MailFilterRule>,
|
||||||
) {
|
) {
|
||||||
val accountId = _state.value.sieveAccountId ?: return
|
val accountId = _state.value.sieveAccountId ?: return
|
||||||
|
// Правки применяем к ТЕКУЩЕМУ локальному списку (он же только что с сервера);
|
||||||
|
// сервер хранит фильтры единым массивом — PUT перезаписывает секцию приложения Mail.
|
||||||
|
val rules = transform(_state.value.sieveRules)
|
||||||
_state.update { it.copy(sieveSaving = true, sieveError = null) }
|
_state.update { it.copy(sieveSaving = true, sieveError = null) }
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
// Перечитываем скрипт перед записью — не затираем изменения из веба.
|
repository.putMailFilters(session, accountId, rules)
|
||||||
val current = repository.getSieveScript(session, accountId)
|
repository.getMailFilters(session, accountId)
|
||||||
val rules = transform(MailSieveScript.parseRules(current))
|
}.onSuccess { fresh ->
|
||||||
repository.putSieveScript(session, accountId, MailSieveScript.renderScript(current, rules))
|
_state.update { it.copy(sieveRules = fresh, sieveSaving = false) }
|
||||||
rules
|
|
||||||
}.onSuccess { rules ->
|
|
||||||
_state.update { it.copy(sieveRules = rules, sieveSaving = false) }
|
|
||||||
}.onFailure { t ->
|
}.onFailure { t ->
|
||||||
_state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") }
|
_state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.mail
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MailFilterRuleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun simple_rule_builds_web_compatible_json() {
|
||||||
|
val rule = MailFilterRule.simple(
|
||||||
|
name = "Работа",
|
||||||
|
field = "subject",
|
||||||
|
contains = "Отчёт",
|
||||||
|
action = "fileinto",
|
||||||
|
targetFolder = "Work",
|
||||||
|
)
|
||||||
|
assertEquals("Работа", rule.name)
|
||||||
|
assertTrue(rule.enable)
|
||||||
|
assertEquals("allof", rule.operator)
|
||||||
|
assertEquals(listOf(Triple("subject", "contains", listOf("Отчёт"))), rule.tests)
|
||||||
|
assertEquals("fileinto", rule.actions.single().optString("type"))
|
||||||
|
assertEquals("Work", rule.actions.single().optString("mailbox"))
|
||||||
|
assertTrue(rule.isSimple)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markread_uses_system_flag() {
|
||||||
|
val rule = MailFilterRule.simple("r", "from", "boss", "markread", "")
|
||||||
|
val action = rule.actions.single()
|
||||||
|
assertEquals("addsystemflag", action.optString("type"))
|
||||||
|
assertTrue(action.optString("flag").contains("Seen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withEnable_preserves_unknown_fields() {
|
||||||
|
val raw = JSONObject(
|
||||||
|
"""{"name":"web","enable":true,"operator":"anyof","priority":20,
|
||||||
|
"tests":[{"field":"subject","operator":"is","values":["x"]},
|
||||||
|
{"field":"from","operator":"contains","values":["y"]}],
|
||||||
|
"actions":[{"type":"stop"}],"customField":"keepme"}""",
|
||||||
|
)
|
||||||
|
val rule = MailFilterRule(raw)
|
||||||
|
assertFalse(rule.isSimple)
|
||||||
|
val disabled = rule.withEnable(false)
|
||||||
|
assertFalse(disabled.enable)
|
||||||
|
assertEquals("keepme", disabled.raw.optString("customField"))
|
||||||
|
assertEquals(2, disabled.tests.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package ru.forbion.f7cloud.feature.mail
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class MailSieveScriptTest {
|
|
||||||
|
|
||||||
private val rule = MailSieveRule(
|
|
||||||
id = "r1",
|
|
||||||
name = "Работа в папку",
|
|
||||||
field = "subject",
|
|
||||||
contains = "Отчёт",
|
|
||||||
action = "fileinto",
|
|
||||||
targetFolder = "Work",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun render_and_parse_roundtrip() {
|
|
||||||
val script = MailSieveScript.renderScript("", listOf(rule))
|
|
||||||
assertTrue(script.startsWith("# F7MOBILE-REQUIRE"))
|
|
||||||
assertTrue(script.contains("require [\"fileinto\", \"imap4flags\"];"))
|
|
||||||
assertTrue(script.contains("fileinto \"Work\";"))
|
|
||||||
val parsed = MailSieveScript.parseRules(script)
|
|
||||||
assertEquals(listOf(rule), parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun user_script_is_preserved() {
|
|
||||||
val user = "# личное правило\nif header :contains \"from\" \"boss\" { keep; }"
|
|
||||||
val script = MailSieveScript.renderScript(user, listOf(rule))
|
|
||||||
assertTrue(script.contains("личное правило"))
|
|
||||||
// удаляем наше правило — пользовательский текст остаётся
|
|
||||||
val cleaned = MailSieveScript.renderScript(script, emptyList())
|
|
||||||
assertTrue(cleaned.contains("личное правило"))
|
|
||||||
assertTrue(!cleaned.contains("F7MOBILE"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun quotes_are_escaped() {
|
|
||||||
val tricky = rule.copy(contains = "он сказал \"привет\"", targetFolder = "A\\B")
|
|
||||||
val script = MailSieveScript.renderScript("", listOf(tricky))
|
|
||||||
assertTrue(script.contains("\\\"привет\\\""))
|
|
||||||
val parsed = MailSieveScript.parseRules(script)
|
|
||||||
assertEquals(tricky.contains, parsed.single().contains)
|
|
||||||
assertEquals(tricky.targetFolder, parsed.single().targetFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun markread_and_discard_actions() {
|
|
||||||
val script = MailSieveScript.renderScript(
|
|
||||||
"",
|
|
||||||
listOf(
|
|
||||||
rule.copy(id = "a", action = "markread", targetFolder = ""),
|
|
||||||
rule.copy(id = "b", action = "discard", targetFolder = ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertTrue(script.contains("addflag \"\\\\Seen\";"))
|
|
||||||
assertTrue(script.contains("discard;"))
|
|
||||||
assertEquals(2, MailSieveScript.parseRules(script).size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user