単語フィルタのエンティティのv2対応

This commit is contained in:
tateisu 2023-01-14 13:02:41 +09:00
parent d391a1ab8f
commit 301905c016
10 changed files with 229 additions and 157 deletions

View File

@ -9,9 +9,11 @@ import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootFilter
import jp.juggler.subwaytooter.api.entity.TootFilterContext
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.databinding.ActKeywordFilterBinding
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.launchMain
@ -65,18 +67,22 @@ class ActKeywordFilter
private lateinit var account: SavedAccount
private lateinit var tvAccount: TextView
private lateinit var etPhrase: EditText
private lateinit var cbContextHome: CheckBox
private lateinit var cbContextNotification: CheckBox
private lateinit var cbContextPublic: CheckBox
private lateinit var cbContextThread: CheckBox
private lateinit var cbContextProfile: CheckBox
private val views by lazy {
ActKeywordFilterBinding.inflate(layoutInflater)
}
private lateinit var cbFilterIrreversible: CheckBox
private lateinit var cbFilterWordMatch: CheckBox
private lateinit var tvExpire: TextView
private lateinit var spExpire: Spinner
// private lateinit var tvAccount: TextView
// private lateinit var etPhrase: EditText
// private lateinit var cbContextHome: CheckBox
// private lateinit var cbContextNotification: CheckBox
// private lateinit var cbContextPublic: CheckBox
// private lateinit var cbContextThread: CheckBox
// private lateinit var cbContextProfile: CheckBox
//
// private lateinit var cbFilterIrreversible: CheckBox
// private lateinit var cbFilterWordMatch: CheckBox
// private lateinit var tvExpire: TextView
// private lateinit var spExpire: Spinner
private var loading = false
private var density: Float = 1f
@ -109,13 +115,13 @@ class ActKeywordFilter
if (filterId != null) {
startLoading()
} else {
spExpire.setSelection(1)
etPhrase.setText(intent.getStringExtra(EXTRA_INITIAL_PHRASE) ?: "")
views. spExpire.setSelection(1)
views. etPhrase.setText(intent.getStringExtra(EXTRA_INITIAL_PHRASE) ?: "")
}
} else {
val iv = savedInstanceState.getInt(STATE_EXPIRE_SPINNER, -1)
if (iv != -1) {
spExpire.setSelection(iv)
views. spExpire.setSelection(iv)
}
filterExpire = savedInstanceState.getLong(STATE_EXPIRE_AT, filterExpire)
}
@ -124,7 +130,7 @@ class ActKeywordFilter
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (!loading) {
outState.putInt(STATE_EXPIRE_SPINNER, spExpire.selectedItemPosition)
outState.putInt(STATE_EXPIRE_SPINNER, views.spExpire.selectedItemPosition)
outState.putLong(STATE_EXPIRE_AT, filterExpire)
}
}
@ -138,24 +144,24 @@ class ActKeywordFilter
)
this.density = resources.displayMetrics.density
setContentView(R.layout.act_keyword_filter)
setContentView(views.root)
App1.initEdgeToEdge(this)
fixHorizontalPadding(findViewById(R.id.svContent))
tvAccount = findViewById(R.id.tvAccount)
etPhrase = findViewById(R.id.etPhrase)
cbContextHome = findViewById(R.id.cbContextHome)
cbContextNotification = findViewById(R.id.cbContextNotification)
cbContextPublic = findViewById(R.id.cbContextPublic)
cbContextThread = findViewById(R.id.cbContextThread)
cbContextProfile = findViewById(R.id.cbContextProfile)
cbFilterIrreversible = findViewById(R.id.cbFilterIrreversible)
cbFilterWordMatch = findViewById(R.id.cbFilterWordMatch)
tvExpire = findViewById(R.id.tvExpire)
spExpire = findViewById(R.id.spExpire)
// tvAccount = findViewById(R.id.tvAccount)
// etPhrase = findViewById(R.id.etPhrase)
// cbContextHome = findViewById(R.id.cbContextHome)
// cbContextNotification = findViewById(R.id.cbContextNotification)
// cbContextPublic = findViewById(R.id.cbContextPublic)
// cbContextThread = findViewById(R.id.cbContextThread)
// cbContextProfile = findViewById(R.id.cbContextProfile)
// cbFilterIrreversible = findViewById(R.id.cbFilterIrreversible)
// cbFilterWordMatch = findViewById(R.id.cbFilterWordMatch)
// tvExpire = findViewById(R.id.tvExpire)
// spExpire = findViewById(R.id.spExpire)
findViewById<View>(R.id.btnSave).setOnClickListener(this)
views.btnSave.setOnClickListener(this)
val captionList = arrayOf(
getString(R.string.dont_change),
@ -169,11 +175,11 @@ class ActKeywordFilter
)
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, captionList)
adapter.setDropDownViewResource(R.layout.lv_spinner_dropdown)
spExpire.adapter = adapter
views.spExpire.adapter = adapter
}
private fun showAccount() {
tvAccount.text = AcctColor.getNicknameWithColor(account.acct)
views.tvAccount.text = AcctColor.getNicknameWithColor(account.acct)
}
private fun startLoading() {
@ -207,17 +213,17 @@ class ActKeywordFilter
filterExpire = filter.time_expires_at
etPhrase.setText(filter.phrase)
setContextChecked(filter, cbContextHome, TootFilter.CONTEXT_HOME)
setContextChecked(filter, cbContextNotification, TootFilter.CONTEXT_NOTIFICATIONS)
setContextChecked(filter, cbContextPublic, TootFilter.CONTEXT_PUBLIC)
setContextChecked(filter, cbContextThread, TootFilter.CONTEXT_THREAD)
setContextChecked(filter, cbContextProfile, TootFilter.CONTEXT_PROFILE)
setContextChecked(filter, views.cbContextHome, TootFilterContext.Home)
setContextChecked(filter, views.cbContextNotification, TootFilterContext.Notifications)
setContextChecked(filter, views.cbContextPublic, TootFilterContext.Public)
setContextChecked(filter, views.cbContextThread, TootFilterContext.Thread)
setContextChecked(filter, views.cbContextProfile, TootFilterContext.Account)
cbFilterIrreversible.isChecked = filter.irreversible
cbFilterWordMatch.isChecked = filter.whole_word
views.etPhrase.setText(filter.phrase)
views.cbFilterIrreversible.isChecked = filter.irreversible
views.cbFilterWordMatch.isChecked = filter.whole_word
tvExpire.text = if (filter.time_expires_at == 0L) {
views.tvExpire.text = if (filter.time_expires_at == 0L) {
getString(R.string.filter_expire_unlimited)
} else {
TootStatus.formatTime(this, filter.time_expires_at, false)
@ -230,12 +236,12 @@ class ActKeywordFilter
}
}
private fun setContextChecked(filter: TootFilter, cb: CheckBox, bit: Int) {
cb.isChecked = ((filter.context and bit) != 0)
private fun setContextChecked(filter: TootFilter, cb: CheckBox, fc: TootFilterContext) {
cb.isChecked = filter.hasContext(fc)
}
private fun JsonArray.putContextChecked(cb: CheckBox, key: String) {
if (cb.isChecked) add(key)
private fun JsonArray.putContextChecked(cb: CheckBox, fc:TootFilterContext) {
if (cb.isChecked) add(fc.apiName)
}
private fun save() {
@ -243,22 +249,22 @@ class ActKeywordFilter
val params = buildJsonObject {
put("phrase", etPhrase.text.toString())
put("context", JsonArray().apply {
putContextChecked(cbContextHome, "home")
putContextChecked(cbContextNotification, "notifications")
putContextChecked(cbContextPublic, "public")
putContextChecked(cbContextThread, "thread")
putContextChecked(cbContextProfile, "account")
putContextChecked(views.cbContextHome, TootFilterContext.Home)
putContextChecked(views.cbContextNotification, TootFilterContext.Notifications)
putContextChecked(views.cbContextPublic, TootFilterContext.Public)
putContextChecked(views.cbContextThread, TootFilterContext.Thread)
putContextChecked(views.cbContextProfile, TootFilterContext.Account)
})
put("irreversible", cbFilterIrreversible.isChecked)
put("whole_word", cbFilterWordMatch.isChecked)
put("phrase", views.etPhrase.text.toString())
put("irreversible",views. cbFilterIrreversible.isChecked)
put("whole_word", views.cbFilterWordMatch.isChecked)
var seconds = -1
val i = spExpire.selectedItemPosition
val i = views.spExpire.selectedItemPosition
if (i >= 0 && i < expire_duration_list.size) {
seconds = expire_duration_list[i]
}

View File

@ -38,7 +38,7 @@ fun ActMain.filterDelete(
confirm(R.string.filter_delete_confirm, filter.phrase)
}
var resultFilterList: ArrayList<TootFilter>? = null
var resultFilterList: List<TootFilter>? = null
runApiTask(accessInfo) { client ->
var result =
client.request("/api/v1/filters/${filter.id}", Request.Builder().delete())

View File

@ -1,82 +1,78 @@
package jp.juggler.subwaytooter.api.entity
import android.content.Context
import jp.juggler.subwaytooter.R
import jp.juggler.util.*
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.notBlank
import jp.juggler.util.log.LogCategory
// https://docs.joinmastodon.org/entities/Filter/
// https://docs.joinmastodon.org/entities/V1_Filter/
class TootFilter(src: JsonObject) : TimelineItem() {
class FilterContext(val name: String, val bit: Int, val caption_id: Int)
companion object {
val log = LogCategory("TootFilter")
@Suppress("unused")
const val CONTEXT_ALL = 31
const val CONTEXT_NONE = 0
const val CONTEXT_HOME = 1
const val CONTEXT_NOTIFICATIONS = 2
const val CONTEXT_PUBLIC = 4
const val CONTEXT_THREAD = 8
const val CONTEXT_PROFILE = 16
private val CONTEXT_LIST = arrayOf(
FilterContext("home", CONTEXT_HOME, R.string.filter_home),
FilterContext("notifications", CONTEXT_NOTIFICATIONS, R.string.filter_notification),
FilterContext("public", CONTEXT_PUBLIC, R.string.filter_public),
FilterContext("thread", CONTEXT_THREAD, R.string.filter_thread),
FilterContext("account", CONTEXT_PROFILE, R.string.filter_profile)
)
private val CONTEXT_MAP = CONTEXT_LIST.associateBy { it.name }
private fun parseFilterContext(src: JsonArray?): Int {
var n = 0
src?.stringList()?.forEach { key ->
val v = CONTEXT_MAP[key]
if (v != null) n += v.bit
}
return n
}
private val log = LogCategory("TootFilter")
fun parseList(src: JsonArray?) =
ArrayList<TootFilter>().also { result ->
src?.objectList()?.forEach {
try {
result.add(TootFilter(it))
} catch (ex: Throwable) {
log.e(ex, "TootFilter parse failed.")
}
src?.objectList()?.mapNotNull {
try {
TootFilter(it)
} catch (ex: Throwable) {
log.e(ex, "TootFilter parse failed.")
null
}
}
}
val id: EntityId
val phrase: String
val context: Int
private val expires_at: String? // null is not specified, or "2018-07-06T00:59:13.161Z"
val time_expires_at: Long // 0L if not specified
val irreversible: Boolean
val whole_word: Boolean
init {
id = EntityId.mayDefault(src.string("id"))
phrase = src.string("phrase") ?: error("missing phrase")
context = parseFilterContext(src.jsonArray("context"))
expires_at = src.string("expires_at") // may null
time_expires_at = TootStatus.parseTime(expires_at)
irreversible = src.optBoolean("irreversible")
whole_word = src.optBoolean("whole_word")
}
fun getContextNames(context: Context): ArrayList<String> {
val result = ArrayList<String>()
for (item in CONTEXT_LIST) {
if ((item.bit and this.context) != 0) result.add(context.getString(item.caption_id))
fun parse1(src: JsonObject?) = try {
src?.let { TootFilter(it) }
} catch (ex: Throwable) {
log.e(ex, "TootFilter parse failed.")
null
}
return result
}
val id: EntityId = EntityId.mayDefault(src.string("id"))
// v2
val title: String? = src.string("title")
private val contextBits: Int = TootFilterContext.parseBits(src.jsonArray("context"))
// フィルタの適用先の名前の文字列IDのリスト
val contextNames
get() = TootFilterContext.bitsToNames(contextBits)
// null is not specified, or "2018-07-06T00:59:13.161Z"
private val expires_at: String? = src.string("expires_at")
// 0L if not specified
val time_expires_at: Long = TootStatus.parseTime(expires_at)
// v2: filter_action is "warn" or "hide".
// v1: irreversible boolean flag.
val filter_action: String = src.string("filter_action") ?: run {
// v1
when (src.boolean("irreversible")) {
true -> "hide"
else -> "warn"
}
}
val keywords: List<TootFilterKeyword>? =
src.jsonArray("keywords")?.let { a ->
/* v2 */ a.objectList().map { TootFilterKeyword(it) }
} ?: src.string("phrase").notBlank()?.let {
listOf(
/* v1 */
TootFilterKeyword(
keyword = it,
whole_word = src.boolean("whole_word") ?: false
)
)
}
// フィルタにマッチしたステータスのIDのリスト
val statuses = src.jsonArray("statuses")?.objectList()?.map { TootFilterStatus(it) }
fun hasContext(fc: TootFilterContext) =
contextBits.and(fc.bit) != 0
}

View File

@ -0,0 +1,35 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.subwaytooter.R
import jp.juggler.util.data.JsonArray
import jp.juggler.util.log.LogCategory
enum class TootFilterContext(
// アプリ内で管理するビット
val bit: Int,
// API中の識別子
val apiName: String,
// アプリに表示する文字列のID
val caption_id: Int
) {
Home(1, "home", R.string.filter_home),
Notifications(2, "notifications", R.string.filter_notification),
Public(4, "public", R.string.filter_public),
Thread(8, "thread", R.string.filter_thread),
Account(16, "account", R.string.filter_profile),
;
companion object {
private val log = LogCategory("TootFilterContext")
private val valuesCache = values()
private val apiNameMap = values().associateBy { it.name }
fun parseBits(src: JsonArray?): Int =
src?.stringList()?.mapNotNull { apiNameMap[it]?.bit }?.sum() ?: 0
fun bitsToNames(mask: Int) =
valuesCache.filter { it.bit.and(mask) != 0 }.map { it.caption_id }
}
}

View File

@ -0,0 +1,17 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonObject
class TootFilterKeyword(
var id: EntityId? = null,// v1 has no id
var keyword: String?,
var whole_word: Boolean,
) {
// from Mastodon api/v2/filter
constructor(src: JsonObject) : this(
id = EntityId.mayNull(src.string("id")),
keyword = src.string("keyword"),
whole_word = src.boolean("whole_word") ?: true,
)
}

View File

@ -0,0 +1,15 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.util.data.JsonObject
class TootFilterResult(src: JsonObject) {
val filter: TootFilter? =
TootFilter.parse1(src.jsonObject("filter"))
val keyword_matches: List<String>? =
src.jsonArray("keyword_matches")?.stringList()
val status_matches: EntityId? =
EntityId.mayNull(src.string("status_matches"))
}

View File

@ -0,0 +1,10 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.util.data.JsonObject
// https://docs.joinmastodon.org/entities/FilterStatus/
// Represents a status ID that, if matched, should cause the filter action to be taken.
class TootFilterStatus(src: JsonObject) {
val id = EntityId.mayDefault(src.string("id"))
val status_id = EntityId.mayDefault(src.string("status_id"))
}

View File

@ -39,26 +39,25 @@ val Column.isFilterEnabled: Boolean
// マストドン2.4.3rcのキーワードフィルタのコンテキスト
fun Column.getFilterContext() = when (type) {
ColumnType.STATUS_HISTORY -> null
ColumnType.HOME,
ColumnType.LIST_TL,
ColumnType.MISSKEY_HYBRID,
-> TootFilter.CONTEXT_HOME
-> TootFilterContext.Home
ColumnType.NOTIFICATIONS,
ColumnType.NOTIFICATION_FROM_ACCT,
-> TootFilter.CONTEXT_NOTIFICATIONS
-> TootFilterContext.Notifications
ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
-> TootFilter.CONTEXT_THREAD
ColumnType.DIRECT_MESSAGES,
-> TootFilterContext.Thread
ColumnType.DIRECT_MESSAGES -> TootFilter.CONTEXT_THREAD
ColumnType.PROFILE -> TootFilterContext.Account
ColumnType.PROFILE -> TootFilter.CONTEXT_PROFILE
ColumnType.STATUS_HISTORY -> TootFilter.CONTEXT_NONE
else -> TootFilter.CONTEXT_PUBLIC
else -> TootFilterContext.Public
// ColumnType.MISSKEY_HYBRID や ColumnType.MISSKEY_ANTENNA_TL はHOMEでもPUBLICでもある…
// Misskeyだし関係ないが、NONEにするとアプリ内で完結するフィルタも働かなくなる
}
@ -71,10 +70,7 @@ fun Column.canStatusFilter() =
ColumnType.SEARCH_NOTESTOCK,
ColumnType.STATUS_HISTORY,
-> true
else -> when {
getFilterContext() == TootFilter.CONTEXT_NONE -> false
else -> true
}
else -> getFilterContext() !=null
}
// カラム設定に「すべての画像を隠す」ボタンを含めるなら真
@ -125,13 +121,13 @@ fun Column.canFilterNonPublicToot(): Boolean = when (type) {
else -> false
}
fun Column.onFiltersChanged2(filterList: ArrayList<TootFilter>) {
fun Column.onFiltersChanged2(filterList: List<TootFilter>) {
val newFilter = encodeFilterTree(filterList) ?: return
this.keywordFilterTrees = newFilter
checkFiltersForListData(newFilter)
}
fun Column.onFilterDeleted(filter: TootFilter, filterList: ArrayList<TootFilter>) {
fun Column.onFilterDeleted(filter: TootFilter, filterList: List<TootFilter>) {
if (type == ColumnType.KEYWORD_FILTER) {
val tmpList = ArrayList<TimelineItem>(listData.size)
for (o in listData) {
@ -146,8 +142,7 @@ fun Column.onFilterDeleted(filter: TootFilter, filterList: ArrayList<TootFilter>
fireShowContent(reason = "onFilterDeleted")
}
} else {
val context = getFilterContext()
if (context != TootFilter.CONTEXT_NONE) {
if( getFilterContext() != null){
onFiltersChanged2(filterList)
}
}
@ -386,38 +381,36 @@ fun Column.isFiltered(item: TootNotification): Boolean {
}
// フィルタを読み直してリストを返す。またはnull
suspend fun Column.loadFilter2(client: TootApiClient): ArrayList<TootFilter>? {
suspend fun Column.loadFilter2(client: TootApiClient): List<TootFilter>? {
if (accessInfo.isPseudo || accessInfo.isMisskey) return null
val columnContext = getFilterContext()
if (columnContext == 0) return null
if (getFilterContext() ==null) return null
val result = client.request(ApiPath.PATH_FILTERS)
val jsonArray = result?.jsonArray ?: return null
return TootFilter.parseList(jsonArray)
}
fun Column.encodeFilterTree(filterList: ArrayList<TootFilter>?): FilterTrees? {
fun Column.encodeFilterTree(filterList: List<TootFilter>?): FilterTrees? {
val columnContext = getFilterContext()
if (columnContext == 0 || filterList == null) return null
if (columnContext == null || filterList == null) return null
val result = FilterTrees()
val now = System.currentTimeMillis()
for (filter in filterList) {
if (filter.time_expires_at > 0L && now >= filter.time_expires_at) continue
if ((filter.context and columnContext) != 0) {
if (!filter.hasContext(columnContext)) continue
val validator = when (filter.whole_word) {
true -> WordTrieTree.WORD_VALIDATOR
else -> WordTrieTree.EMPTY_VALIDATOR
}
if (filter.irreversible) {
result.treeIrreversible
} else {
result.treeReversible
}.add(filter.phrase, validator = validator)
result.treeAll.add(filter.phrase, validator = validator)
val validator = when (filter.whole_word) {
true -> WordTrieTree.WORD_VALIDATOR
else -> WordTrieTree.EMPTY_VALIDATOR
}
if (filter.irreversible) {
result.treeIrreversible
} else {
result.treeReversible
}.add(filter.phrase, validator = validator)
result.treeAll.add(filter.phrase, validator = validator)
}
return result
}
@ -452,7 +445,7 @@ fun Column.checkFiltersForListData(trees: FilterTrees?) {
fun reloadFilter(context: Context, accessInfo: SavedAccount) {
launchMain {
var resultList: ArrayList<TootFilter>? = null
var resultList: List<TootFilter>? = null
context.runApiTask(
accessInfo,

View File

@ -905,7 +905,7 @@ class ColumnTask_Loading(
val result = client.request(pathBase)
if (result != null) {
val src = TootFilter.parseList(result.jsonArray)
this.listTmp = addAll(null, src)
this.listTmp = addAll(null, src?: emptyList())
}
return result
}

View File

@ -400,7 +400,7 @@ fun ItemViewHolder.showFilter(filter: TootFilter) {
//
sb.append(activity.getString(R.string.filter_context))
.append(": ")
.append(filter.getContextNames(activity).joinToString("/"))
.append(filter.contextNames.joinToString("/") { activity.getString(it) })
//
val flags = ArrayList<String>()
if (filter.irreversible) flags.add(activity.getString(R.string.filter_irreversible))