improve hashtag detection when compositing new status

This commit is contained in:
tateisu 2019-07-20 15:32:43 +09:00
parent 091d54255d
commit e653bfd467
4 changed files with 139 additions and 51 deletions

View File

@ -1,11 +1,12 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.util.MisskeyMarkdownDecoder
import jp.juggler.util.notEmptyOrThrow
import jp.juggler.util.parseString
import org.json.JSONArray
import org.json.JSONObject
import java.util.regex.Pattern
open class TootTag(
// The hashtag, not including the preceding #
@ -20,11 +21,11 @@ open class TootTag(
)
companion object {
// 検索結果のhashtagリストから生成する
fun parseTootTagList(parser:TootParser,array : JSONArray?) : ArrayList<TootTag> {
fun parseTootTagList(parser : TootParser, array : JSONArray?) : ArrayList<TootTag> {
val result = ArrayList<TootTag>()
if( parser.serviceType == ServiceType.MISSKEY){
if(parser.serviceType == ServiceType.MISSKEY) {
if(array != null) {
for(i in 0 until array.length()) {
val sv = array.parseString(i)
@ -34,7 +35,7 @@ open class TootTag(
}
}
}else {
} else {
if(array != null) {
for(i in 0 until array.length()) {
val sv = array.parseString(i)
@ -48,5 +49,61 @@ open class TootTag(
return result
}
// \p{L} : アルファベット (Letter)。
//   Ll(小文字)、Lm(擬似文字)、Lo(その他の文字)、Lt(タイトル文字)、Lu(大文字アルファベット)を含む
// \p{M} : 記号 (Mark)
// \p{Nd} : 10 進数字 (Decimal number)
// \p{Pc} : 連結用句読記号 (Connector punctuation)
// rubyの [:word:] 単語構成文字 (Letter | Mark | Decimal_Number | Connector_Punctuation)
private val w = """\p{L}\p{M}\p{Nd}\p{Pc}"""
// rubyの [:alpha:] : 英字 (Letter | Mark)
private val a = """\p{L}\p{M}"""
// 2019/7/20 https://github.com/tootsuite/mastodon/pull/11363/files
private val reTagMastodon : Pattern =
Pattern.compile("""(?:^|[^\w)])#([_$w][·_$w]*[·_$a][·_$w]*[_$w]|[_$w]*[$a][_$w]*)""")
// https://medium.com/@alice/some-article#.abcdef123 => タグにならない
// https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit => タグにならない
// # => #
// #3d => #3d
// #l33ts35k => #l33ts35k
// #world2016 => #world2016
// #_test => #_test
// #test_ => #test_
// #one·two·three· => 末尾の・はタグに含まれない。#one·two·three までがハッシュタグになる。
// #0123456' => 数字だけのハッシュタグはタグとして認識されない。
// #000_000 => 認識される。
//
// タグに使えない文字
// 入力補完用なのでやや緩め
private val reCharsNotTagMastodon = Pattern.compile("""[^·_$w$a]""")
private val reCharsNotTagMisskey = Pattern.compile("""[\s.,!?'${'"'}:/\[\]【】]""")
// find hashtags in content text(raw)
// returns null if hashtags not found, or ArrayList of String (tag without #)
fun findHashtags(src : String, isMisskey : Boolean) : ArrayList<String>? =
if(isMisskey) {
MisskeyMarkdownDecoder.findHashtags(src)
} else {
var result : ArrayList<String>? = null
val m = reTagMastodon.matcher(src)
while(m.find()) {
if(result == null) result = ArrayList()
result.add(m.group(1))
}
result
}
fun isValid(src : String, isMisskey : Boolean) =
if(isMisskey) {
! reCharsNotTagMisskey.matcher(src).find()
} else {
! reCharsNotTagMastodon.matcher(src).find()
}
}
}

View File

@ -5,20 +5,20 @@ enum class TootVisibility(
, val order : Int // 公開範囲の広い方とWeb設定に合わせる方が大きい
, val strMastodon : String
, val strMisskey : String
, @Suppress("unused") val isLocal :Boolean = false
, @Suppress("unused") val isLocal : Boolean = false
) {
// IDは下書き保存などで永続化するので、リリース後は変更しないこと
// アカウント設定に合わせる。
AccountSetting(-1, 200, strMastodon = "account_setting", strMisskey = "account_setting"),
AccountSetting(- 1, 200, strMastodon = "account_setting", strMisskey = "account_setting"),
// WebUIの設定に合わせる。
WebSetting(0, 100, strMastodon = "web_setting", strMisskey = "web_setting"),
// 公開TLに流す
Public(1, 90, strMastodon = "public", strMisskey = "public"),
LocalPublic(6, 85, strMastodon = "public", strMisskey = "local-public",isLocal = true),
LocalPublic(6, 85, strMastodon = "public", strMisskey = "local-public", isLocal = true),
// LTL,FTLには表示されない。
// フォロワーのホームには表示される。
@ -26,7 +26,7 @@ enum class TootVisibility(
// (Mastodon)タグTLには出ない。
// (Misskey)タグTLには出る。
UnlistedHome(2, 80, strMastodon = "unlisted", strMisskey = "home"),
LocalHome(6, 75, strMastodon = "unlisted", strMisskey = "local-home",isLocal = true),
LocalHome(6, 75, strMastodon = "unlisted", strMisskey = "local-home", isLocal = true),
// 未フォローには見せない。
// (Mastodon)フォロワーのHTLに出る。
@ -36,8 +36,8 @@ enum class TootVisibility(
// (Misskey)非ログインの閲覧者から見たのタグTLには出るが内容は隠される。「あの人はエロゲのタグで何か話してた」とか分かっちゃう。
// (Misskey)非ログインの閲覧者から見たのプロフには出るが内容は隠される。「あの人は寝てるはずの時間に何か投稿してた」とか分かっちゃう。
PrivateFollowers(3, 70, strMastodon = "private", strMisskey = "followers"),
LocalFollowers(3, 65, strMastodon = "private", strMisskey = "local-followers",isLocal = true),
LocalFollowers(3, 65, strMastodon = "private", strMisskey = "local-followers", isLocal = true),
// 指定したユーザにのみ送信する。
// (Misskey)送信先ユーザのIDをリストで指定する。投稿前にユーザの存在確認を行う機会がある。
// (Misskey)送信先ユーザが1以上ならspecified、0ならprivateを指定する。
@ -65,24 +65,46 @@ enum class TootVisibility(
}
}
fun isTagAllowed(isMisskey : Boolean) =
if(isMisskey)
when(this) {
// 以下の二つの指定ではチェックを行えないので許可扱いにする
AccountSetting, WebSetting -> true
// Misskeyは公開とホームに書いたタグはタグTLに出る
Public, LocalPublic, UnlistedHome, LocalHome -> true
else -> false
}
else
when(this) {
// 以下の二つの指定ではチェックを行えないので許可扱いにする
AccountSetting, WebSetting -> true
// Mastodon は公開のみタグTLに出る
Public, LocalPublic -> true
else -> false
}
companion object {
fun parseMastodon(a : String?) : TootVisibility? {
for(v in TootVisibility.values()) {
for(v in values()) {
if(v.strMastodon == a) return v
}
return null
}
fun parseMisskey(a : String?,localOnly:Boolean = false ) : TootVisibility? {
for(v in TootVisibility.values()) {
if(v.strMisskey == a){
if( localOnly ){
when(v){
fun parseMisskey(a : String?, localOnly : Boolean = false) : TootVisibility? {
for(v in values()) {
if(v.strMisskey == a) {
if(localOnly) {
when(v) {
Public -> return LocalPublic
UnlistedHome -> return LocalHome
PrivateFollowers -> return LocalFollowers
else->{}
else -> {
}
}
}
return v
@ -92,7 +114,7 @@ enum class TootVisibility(
}
fun fromId(id : Int) : TootVisibility? {
for(v in TootVisibility.values()) {
for(v in values()) {
if(v.id == id) return v
}
return null
@ -102,12 +124,12 @@ enum class TootVisibility(
sv ?: return null
// 新しい方式ではenumのID
for(v in TootVisibility.values()) {
for(v in values()) {
if(v.id.toString() == sv) return v
}
// 古い方式ではマストドンの公開範囲文字列かweb_setting
for(v in TootVisibility.values()) {
for(v in values()) {
if(v.strMastodon == sv) return v
}

View File

@ -124,7 +124,7 @@ private val bracketsMap = HashMap<Char, Int>().apply {
}
private val bracketsMapUrlSafe = HashMap<Char, Int>().apply {
brackets.forEach {
if( "([".contains(it[0]) ) return@forEach
if("([".contains(it[0])) return@forEach
put(it[0], 1)
put(it[1], - 1)
}
@ -132,24 +132,24 @@ private val bracketsMapUrlSafe = HashMap<Char, Int>().apply {
// 末尾の余計な」や(を取り除く。
// 例えば「#タグ」 とか #タグ)
fun String.removeOrphanedBrackets(urlSafe:Boolean =false) : String {
fun String.removeOrphanedBrackets(urlSafe : Boolean = false) : String {
var last = 0
val nests = when(urlSafe){
true->this.map {
val nests = when(urlSafe) {
true -> this.map {
last += bracketsMapUrlSafe[it] ?: 0
last
}
else->this.map {
else -> this.map {
last += bracketsMap[it] ?: 0
last
}
}
// first position of unmatched close
var pos = nests.indexOfFirst { it < 0 }
if(pos != - 1) return substring(0, pos)
// last position of unmatched open
pos = nests.indexOfLast { it == 0 }
return substring(0, pos + 1)
@ -1237,6 +1237,7 @@ object MisskeyMarkdownDecoder {
NodeType.QUOTE_BLOCK, NodeType.QUOTE_INLINE -> 1
else -> 0
}
}
// マークダウン要素の出現位置
@ -1727,6 +1728,27 @@ object MisskeyMarkdownDecoder {
)
}
// 入力テキストからタグを抽出するために使う
// #を含まないタグ文字列のリスト、またはnullを返す
fun findHashtags(src : String?) : ArrayList<String>? {
try {
if(src != null) {
val root = Node(NodeType.ROOT, emptyArray(), null)
NodeParseEnv(root, src, 0, src.length).parseInside()
val result = ArrayList<String>()
fun track(n : Node) {
if(n.type == NodeType.HASHTAG) result.add(n.args[0])
n.childNodes.forEach { track(n) }
}
track(root)
if(result.isNotEmpty()) return result
}
} catch(ex : Throwable) {
log.e(ex, "findHashtags failed.")
}
return null
}
// このファイルのエントリーポイント
fun decodeMarkdown(options : DecodeOptions, src : String?) =
SpannableStringBuilderEx().apply {

View File

@ -31,6 +31,7 @@ import org.json.JSONObject
import java.lang.ref.WeakReference
import java.util.*
import java.util.regex.Pattern
import kotlin.math.min
class PostHelper(
private val activity : AppCompatActivity,
@ -41,20 +42,7 @@ class PostHelper(
companion object {
private val log = LogCategory("PostHelper")
// [:word:] 単語構成文字 (Letter | Mark | Decimal_Number | Connector_Punctuation)
// [:alpha:] 英字 (Letter | Mark)
private const val word = "[_\\p{L}\\p{M}\\p{Nd}\\p{Pc}]"
private const val alpha = "[_\\p{L}\\p{M}]"
private val reTag = Pattern.compile(
"(?:^|[^/)\\w])#($word*$alpha$word*)", Pattern.CASE_INSENSITIVE
)
private val reCharsNotTag = Pattern.compile("[・\\s\\-+.,:;/]")
private val reCharsNotEmoji = Pattern.compile("[^0-9A-Za-z_-]")
}
interface PostCompleteCallback {
@ -177,10 +165,9 @@ class PostHelper(
if(! bConfirmTag) {
if(! account.isMisskey
&& visibility != TootVisibility.Public
&& reTag.matcher(content).find()
) {
val tags = TootTag.findHashtags(content,account.isMisskey)
if( tags != null && !visibility.isTagAllowed(isMisskey = account.isMisskey) ){
log.d("findHashtags ${tags.joinToString(",")}")
AlertDialog.Builder(activity)
.setCancelable(true)
.setMessage(R.string.hashtag_and_visibility_not_match)
@ -736,7 +723,7 @@ class PostHelper(
}
val part = src.substring(last_sharp + 1, end)
if(reCharsNotTag.matcher(part).find()) {
if( ! TootTag.isValid(part, isMisskey) ){
checkEmoji(et, src)
return
}
@ -998,7 +985,7 @@ class PostHelper(
val src = et.text ?: ""
val src_length = src.length
val end = Math.min(src_length, et.selectionEnd)
val end = min(src_length, et.selectionEnd)
val start = src.lastIndexOf(':', end - 1)
if(start == - 1 || end - start < 1) return@EmojiPicker
@ -1026,8 +1013,8 @@ class PostHelper(
val src = et.text ?: ""
val src_length = src.length
val start = Math.min(src_length, et.selectionStart)
val end = Math.min(src_length, et.selectionEnd)
val start = min(src_length, et.selectionStart)
val end = min(src_length, et.selectionEnd)
val sb = SpannableStringBuilder()
.append(src.subSequence(0, start))