SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt

1739 lines
68 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.api.entity
import android.annotation.SuppressLint
import android.content.Context
import android.os.SystemClock
import android.text.Spannable
import android.text.SpannableString
import androidx.annotation.StringRes
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootAccountMap
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRef
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.*
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.HTMLDecoder
import jp.juggler.util.*
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import java.lang.ref.WeakReference
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.atomic.AtomicLong
import java.util.regex.Pattern
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
class FilterTrees(
val treeHide: WordTrieTree = WordTrieTree(),
val treeWarn: WordTrieTree = WordTrieTree(),
val treeAll: WordTrieTree = WordTrieTree(),
)
@Suppress("MemberVisibilityCanPrivate")
class TootStatus(
parser: TootParser,
val json: JsonObject,
// A Fediverse-unique resource ID
// MSP から取得したデータだと uri は提供されずnullになる
val uri: String,
// URL to the status page (can be remote)
// ブーストだとnullになる
val url: String?,
// 取得タンスのホスト名。トゥート検索サービスでは提供されずnullになる
val readerApDomain: Host?,
// ステータスID。
// host_access が null の場合は投稿元タンスでのIDかもしれない。
// 取得に失敗するとINVALID_IDになる
// Misskeyでは文字列のID。
val id: EntityId,
// misskeyではページングIDにRelation ID が別途提供されることがある
var _orderId: EntityId? = null,
// The TootAccount which posted the status
val accountRef: TootAccountRef,
//The number of reblogs for the status
// アプリから変更する。検索サービスでは提供されない(null)
var reblogs_count: Long? = null,
//The number of favourites for the status
// アプリから変更する。検索サービスでは提供されない(null)
var favourites_count: Long? = null,
// Whether the authenticated user has reblogged the status
// アプリから変更する
var reblogged: Boolean = false,
// Whether the authenticated user has favourited the status
// アプリから変更する
var favourited: Boolean = false,
// Whether the authenticated user has bookmarked the status
// アプリから変更する
var bookmarked: Boolean = false,
// Whether the authenticated user has muted the conversation this status from
// アプリから変更する
var muted: Boolean = false,
// 固定されたトゥート
// アプリから変更する
var pinned: Boolean = false,
//Whether media attachments should be hidden by default
val sensitive: Boolean,
// The detected language for the status, if detected
val language: String?,
//If not empty, warning text that should be displayed before the actual content
// アプリ内部では空文字列はCWなしとして扱う
// マストドンは「null:CWなし」「空じゃない文字列CWあり」の2種類
// Pleromaは「空文字列CWなし」「空じゃない文字列CWあり」の2種類
// Misskeyは「CWなし」「空欄CW」「CWあり」の3通り。空欄CWはパース時に書き換えてしまう
// Misskeyで投稿が削除された時に変更されるため、val変数にできない
var spoiler_text: String = "",
var decoded_spoiler_text: Spannable,
// Body of the status; this will contain HTML (remote HTML already sanitized)
var content: String?,
var decoded_content: Spannable,
//Application from which the status was posted
val application: TootApplication?,
var custom_emojis: MutableMap<String, CustomEmoji>?,
val profile_emojis: Map<String, NicoProfileEmoji>?,
// The time the status was created
private val created_at: String?,
// null or the ID of the status it replies to
val in_reply_to_id: EntityId?,
// null or the ID of the account it replies to
val in_reply_to_account_id: EntityId?,
// null or the reblogged Status
// 投稿の更新が実装されたのでvarになった
var reblog: TootStatus? = null,
//One of: public, unlisted, private, direct
val visibility: TootVisibility,
private val misskeyVisibleIds: ArrayList<String>?,
// An array of Attachments
val media_attachments: ArrayList<TootAttachmentLike>?,
// An array of Mentions
var mentions: ArrayList<TootMention>? = null,
//An array of Tags
var tags: List<TootTag>? = null,
// public Spannable decoded_tags;
var decoded_mentions: Spannable = EMPTY_SPANNABLE,
var enquete: TootPolls? = null,
//
var replies_count: Long? = null,
var viaMobile: Boolean = false,
var reactionSet: TootReactionSet? = null,
var reply: TootStatus?,
val serviceType: ServiceType,
val deletedAt: String?,
val time_deleted_at: Long,
private var localOnly: Boolean = false,
var myRenoteId: EntityId? = null,
// reblog,reply された投稿からその外側を参照する
var reblogParent: TootStatus? = null,
// quote toot かどうか。
var isQuoteToot: Boolean = false,
private var quote_id: EntityId? = null,
// このstatusがquoteだった場合、ミュート済みかどうか示すフラグ
var quote_muted: Boolean = false,
// Misskey 12.3
var isPromoted: Boolean = false,
var isFeatured: Boolean = false,
// Mastodon 3.5.0
var time_edited_at: Long = 0L,
// Mastodon 4.0.0
var filteredV4: List<TootFilterResult>? = null,
///////////////////////////////////////////////////////////////////
// 以下はentityから取得したデータではなく、アプリ内部で使う
// アプリ内部で使うワークエリア
var auto_cw: AutoCW? = null,
// 会話の流れビューで後から追加する
var card: TootCard? = null,
var highlightSound: HighlightWord? = null,
var highlightSpeech: HighlightWord? = null,
var highlightAny: HighlightWord? = null,
val time_created_at: Long,
) : TimelineItem() {
// 会話カラムの場合に使う
var conversationSummary: TootConversationSummary? = null
var conversation_main: Boolean = false
// 投稿元タンスのホスト名
val originalApDomain: Host
get() = account.apDomain
val account: TootAccount
get() = TootAccountMap.find(accountRef.mapId)
override fun getOrderId() = _orderId ?: id
class AutoCW(
var refActivity: WeakReference<Any>? = null,
var cellWidth: Int = 0,
var decodedSpoilerText: Spannable? = null,
var originalLineCount: Int = 0,
)
init {
decoded_mentions = HTMLDecoder.decodeMentions(parser, this) ?: EMPTY_SPANNABLE
this.reblog?.reblogParent = this
}
///////////////////////////////////////////////////
// ユーティリティ
// メディア表示を隠したかどうかのキーに使われる
// APドメイン名
val hostAccessOrOriginal: Host
get() = readerApDomain?.valid() ?: originalApDomain.valid() ?: Host.UNKNOWN
val busyKey: String
get() = "${hostAccessOrOriginal.ascii}:$id"
fun checkMuted(): Boolean {
// app mute
if (application?.name?.let { muted_app?.contains(it) } == true) {
return true
}
// word mute
muted_word?.run {
if (matchShort(decoded_content)) return true
if (matchShort(decoded_spoiler_text)) return true
}
// reblog
return true == reblog?.checkMuted()
}
fun hasMedia(): Boolean {
return (media_attachments?.size ?: 0) > 0
}
fun canPin(accessInfo: SavedAccount): Boolean =
reblog == null &&
accessInfo.isMe(account) &&
visibility.canPin(accessInfo.isMisskey)
// 内部で使う
private var _filteredWord: String? = null
val filteredWord: String?
get() = _filteredWord ?: reblog?._filteredWord
val filtered: Boolean
get() = filteredWord != null
private fun hasReceipt(accessInfo: SavedAccount): TootVisibility {
val fullAcctMe = accessInfo.getFullAcct(account)
val reply_account = reply?.account
if (reply_account != null && fullAcctMe != accessInfo.getFullAcct(reply_account)) {
return TootVisibility.DirectSpecified
}
val in_reply_to_account_id = this.in_reply_to_account_id
if (in_reply_to_account_id != null && in_reply_to_account_id != account.id) {
return TootVisibility.DirectSpecified
}
mentions?.forEach {
if (fullAcctMe != accessInfo.getFullAcct(it.acct)) {
return@hasReceipt TootVisibility.DirectSpecified
}
}
return TootVisibility.DirectPrivate
}
fun getBackgroundColorType(accessInfo: SavedAccount) =
when (visibility) {
TootVisibility.DirectPrivate,
TootVisibility.DirectSpecified,
-> hasReceipt(accessInfo)
else -> visibility
}
fun updateKeywordFilteredFlag(
accessInfo: SavedAccount,
trees: FilterTrees?,
matchedFiltersV4: List<TootFilterResult>? = null,
// フィルタ更新時などは隠すフィルタも含めてチェックする
checkAll: Boolean = false,
) {
trees ?: return
val desc = if (accessInfo.isMe(account) || accessInfo.isMe(reblog?.account)) {
null
} else {
val tree = if (checkAll) trees.treeAll else trees.treeWarn
val m1 = matchKeywordFilter(accessInfo, tree)
val m2 = reblog?.matchKeywordFilter(accessInfo, tree)
if (m1.isNullOrEmpty() &&
m2.isNullOrEmpty() &&
matchedFiltersV4.isNullOrEmpty()
) {
null
} else {
val list = ArrayList<String>()
fun String.addToList() {
if (this.isNotEmpty() && !list.contains(this)) list.add(this)
}
fun List<String>.addToList() {
for (s in this) s.addToList()
}
matchedFiltersV4?.forEach { it.filter?.title?.addToList() }
m1?.forEach { m ->
m.tags?.mapNotNull { (it as? TootFilter)?.title }
?.addToList()
}
m2?.forEach { m ->
m.tags?.mapNotNull { (it as? TootFilter)?.title }
?.addToList()
}
if (list.isEmpty()) {
matchedFiltersV4?.forEach { fr ->
fr.filter?.keywords?.map { it.keyword }?.addToList()
}
m1?.forEach { m ->
m.word.notEmpty()?.let { list.add(it) }
}
m2?.forEach { m ->
m.word.notEmpty()?.let { list.add(it) }
}
}
list.joinToString(", ")
}
}
_filteredWord = desc
reblog?._filteredWord = desc
}
fun matchKeywordFilterWithReblog(
accessInfo: SavedAccount,
tree: WordTrieTree?,
): List<WordTrieTree.Match>? {
matchKeywordFilter(accessInfo, tree)
?.notEmpty()?.let { return it }
reblog?.matchKeywordFilter(accessInfo, tree)
?.notEmpty()?.let { return it }
return null
}
private fun matchKeywordFilter(
accessInfo: SavedAccount,
tree: WordTrieTree?,
): ArrayList<WordTrieTree.Match>? {
// フィルタ単語がない、または
if (tree.isNullOrEmpty() || accessInfo.isMe(account)) return null
var list: ArrayList<WordTrieTree.Match>? = null
fun check(t: CharSequence?) {
if (t.isNullOrEmpty()) return
val matches = tree.matchList(t) ?: return
(list ?: ArrayList<WordTrieTree.Match>().also { list = it })
.addAll(matches)
}
check(decoded_spoiler_text)
check(decoded_content)
media_attachments?.forEach { check(it.description) }
return list
}
fun updateReactionMastodon(newReactionSet: TootReactionSet) {
synchronized(this) {
this.reactionSet = newReactionSet
}
}
fun updateReactionMastodonByEvent(newReaction: TootReaction) {
synchronized(this) {
var reactionSet = this.reactionSet
if (newReaction.count <= 0) {
reactionSet?.get(newReaction.name)?.let { reactionSet?.remove(it) }
} else {
if (reactionSet == null) {
reactionSet = TootReactionSet(isMisskey = false)
this.reactionSet = reactionSet
}
when (val old = reactionSet[newReaction.name]) {
null -> reactionSet.add(newReaction)
// 同一オブジェクトならマージは不要
newReaction -> {
}
// 異なるオブジェクトの場合はmeを壊さないようにカウントだけ更新する
else -> old.count = newReaction.count
}
}
}
}
// return true if updated
fun increaseReactionMisskey(
code: String?,
byMe: Boolean,
emoji: CustomEmoji? = null,
caller: String,
): Boolean {
code ?: return false
synchronized(this) {
if (emoji != null) {
if (custom_emojis == null) custom_emojis = HashMap()
custom_emojis?.put(emoji.mapKey, emoji)
}
var reactionSet = this.reactionSet
if (reactionSet == null) {
reactionSet = TootReactionSet(isMisskey = true)
this.reactionSet = reactionSet
}
if (byMe) {
// 自分でリアクションしたらUIで更新した後にストリーミングイベントが届くことがある
// その場合はカウントを変更しない
if (reactionSet.any { it.me && it.name == code }) return false
}
log.d("increaseReaction noteId=$id byMe=$byMe caller=$caller")
// カウントを増やす
val reaction =
reactionSet[code]?.also { it.count = max(0, it.count + 1L) }
?: TootReaction(name = code, count = 1L).also { reactionSet.add(it) }
if (byMe) reaction.me = true
return true
}
}
fun decreaseReactionMisskey(
code: String?,
byMe: Boolean,
caller: String,
): Boolean {
code ?: return false
synchronized(this) {
val reactionSet = this.reactionSet ?: return false
if (byMe) {
// 自分でリアクションしたらUIで更新した後にストリーミングイベントが届くことがある
// その場合はカウントを変更しない
if (reactionSet.any { !it.me && it.name == code }) return false
}
log.d("decreaseReaction noteId=$id byMe=$byMe caller=$caller")
// カウントを減らす
val reaction = reactionSet[code]
?.also { it.count = max(0L, it.count - 1L) }
if (byMe) reaction?.me = false
return true
}
}
fun markDeleted(context: Context, deletedAt: Long?): Boolean {
if (PrefB.bpDontRemoveDeletedToot.value) return false
var sv = if (deletedAt != null) {
context.getString(R.string.status_deleted_at, formatTime(context, deletedAt, false))
} else {
context.getString(R.string.status_deleted)
}
this.content = sv
this.decoded_content = SpannableString(sv)
sv = ""
this.spoiler_text = sv
this.decoded_spoiler_text = SpannableString(sv)
return true
}
class FindStatusIdFromUrlResult(
val statusId: EntityId?, // may null
hostArg: String,
val url: String,
val isReference: Boolean = false,
) {
val host = Host.parse(hostArg)
}
companion object {
internal val log = LogCategory("TootStatus")
@Volatile
internal var muted_app: Set<String>? = null
@Volatile
internal var muted_word: WordTrieTree? = null
@Volatile
internal var favMuteSet: Set<Acct>? = null
private val timeMuteData = AtomicLong(0L)
private const val MUTE_DATA_EXPIRE = 120_000L
private fun mergeMentions(
mentions1: List<TootMention>?,
mentions2: List<TootMention>?,
): ArrayList<TootMention>? {
val size = (mentions1?.size ?: 0) + (mentions2?.size ?: 0)
if (size == 0) return null
val dst = ArrayList<TootMention>(size)
if (mentions1 != null) dst.addAll(mentions1)
if (mentions2 != null) dst.addAll(mentions2)
return dst
}
private fun statusMisskey(parser: TootParser, src: JsonObject): TootStatus {
src["_fromStream"] = parser.fromStream
val apiHost = parser.apiHost
val misskeyId = src.string("id")
val id = EntityId.mayDefault(misskeyId)
var uri = "https://$apiHost/notes/$misskeyId"
var url = "https://$apiHost/notes/$misskeyId"
// リモート投稿には uriが含まれる
src.string("uri")?.let {
uri = it
url = it
}
val who = parser.account(src.jsonObject("user"))
?: error("missing account")
val accountRef = tootAccountRef(parser, who)
val account = accountRef.get()
val created_at = src.string("createdAt")
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく
var custom_emojis: MutableMap<String, CustomEmoji>? =
parseMapOrNull(src.jsonArray("emojis"),CustomEmoji::decodeMisskey)
val reactionEmojis: MutableMap<String, CustomEmoji>? =
CustomEmoji.decodeMisskey12ReactionEmojis(src.jsonObject("reactionEmojis"))
if (reactionEmojis != null) {
custom_emojis = when (custom_emojis) {
null -> reactionEmojis
else -> (reactionEmojis + custom_emojis).toMutableMap()
}
}
// Misskeyは画像毎にNSFWフラグがある。どれか枚でもNSFWならトゥート全体がNSFWということにする
var sensitive = src.optBoolean("sensitive")
val media_attachments = parseListOrNull(
src.jsonArray("files") ?: src.jsonArray("media") // v11,v10
) {
@Suppress("USELESS_CAST")
tootAttachment(parser, it) as TootAttachmentLike
}
media_attachments?.forEach {
if ((it as? TootAttachment)?.isSensitive == true) {
sensitive = true
}
}
val spoilerRaw = src.string("cw")?.cleanCW()
val profile_emojis = null
val options1 = DecodeOptions(
parser.context,
parser.linkHelper,
short = true,
decodeEmoji = true,
emojiMapCustom = custom_emojis,
emojiMapProfile = profile_emojis,
attachmentList = media_attachments,
highlightTrie = parser.highlightTrie,
mentions = null, // MisskeyはMFMをパースし終わるまでメンションが分からない
authorDomain = accountRef.get()
)
// ハイライト検出のためにDecodeOptionsを作り直す
val options2 = DecodeOptions(
parser.context,
parser.linkHelper,
short = true,
decodeEmoji = true,
emojiMapCustom = custom_emojis,
emojiMapProfile = profile_emojis,
attachmentList = media_attachments,
highlightTrie = parser.highlightTrie,
mentions = null, // MisskeyはMFMをパースし終わるまでメンションが分からない
authorDomain = accountRef.get()
)
val content = src.string("text")
val spoiler_text = when {
spoilerRaw == null -> "" // CWなし
spoilerRaw.replace('\u0323', ' ').isBlank() ->
parser.context.getString(R.string.blank_cw)
else -> spoilerRaw
}
// Markdownのデコード結果からmentionsを読む
val decoded_content = options1.decodeHTML(content)
val mentions1 = (decoded_content as? SpannableStringBuilderEx)?.mentions
var highlightSound = options1.highlightSound
var highlightSpeech = options1.highlightSpeech
var highlightAny = options1.highlightAny
val decoded_spoiler_text = options2.decodeHTML(spoiler_text)
val mentions2 = (decoded_spoiler_text as? SpannableStringBuilderEx)?.mentions
if (highlightSound == null) highlightSound = options2.highlightSound
if (highlightSpeech == null) highlightSpeech = options2.highlightSpeech
if (highlightAny == null) highlightAny = options2.highlightAny
val reply = parser.status(src.jsonObject("reply"))
val reblog = parser.status(src.jsonObject("renote"))
val isQuoteToot = when (reblog) {
// 別の投稿を参照していない
null -> false
// 別の投稿を参照して、かつ この投稿自体が何かコンテンツを持つなら引用トゥートである
else -> content?.isNotEmpty() == true ||
spoiler_text.isNotEmpty() ||
media_attachments?.isNotEmpty() == true ||
src.jsonObject("poll") != null
}
val card: TootCard? = when {
// 引用Renoteにプレビューカードをでっちあげる
reblog != null && isQuoteToot -> {
TootCard.tootCard(parser, reblog)
}
// 返信にプレビューカードをでっちあげる
reply != null -> {
TootCard.tootCard(parser, reply)
}
else -> null
}
// めいめいフォークでは myRenoteIdというものがあるらしい
// https://github.com/mei23/misskey/blob/mei-m544/src/models/note.ts#L384-L394
// 直近の一つのrenoteのIdを得られるらしい。
var reblogged = false
val myRenoteId = EntityId.mayNull(src.string("myRenoteId"))
if (myRenoteId != null) reblogged = true
// しかしTLにRenoteが露出してるならそのIDを使う方が賢明であろう
// 外側ステータスが自分なら、内側ステータスのmyRenoteIdを設定する
if (reblog != null && parser.linkHelper.cast<SavedAccount>()
?.isMe(account) == true
) {
reblog.myRenoteId = id
reblog.reblogged = true
}
val deletedAt = src.string("deletedAt")
val localOnly = src.optBoolean("localOnly")
// お気に入りカラムなどではパース直後に変更することがある
return TootStatus(
// "mentionedRemoteUsers" -> "[{"uri":"https:\/\/mastodon.juggler.jp\/users\/tateisu","username":"tateisu","host":"mastodon.juggler.jp"}]"
// this.decoded_tags = HTMLDecoder.decodeTags( account,status.tags );
accountRef = accountRef,
// auto_cw
// bookmarked
card = card,
// conversationSummary
// conversation_main
content = content,
created_at = created_at,
custom_emojis = custom_emojis,
// decoded_mentions
decoded_content = decoded_content,
decoded_spoiler_text = decoded_spoiler_text,
deletedAt = deletedAt,
favourited = src.optBoolean("isFavorited"),
favourites_count = 0L,
// filteredV4
highlightAny = highlightAny,
highlightSound = highlightSound,
highlightSpeech = highlightSpeech,
id = id,
in_reply_to_account_id = reply?.account?.id,
in_reply_to_id = EntityId.mayNull(src.string("replyId")),
isFeatured = src.string("_featuredId_")?.isNotEmpty() == true,
isPromoted = src.string("_prId_")?.isNotEmpty() == true,
isQuoteToot = isQuoteToot,
json = src,
language = null,
localOnly = localOnly,
media_attachments = media_attachments,
mentions = mergeMentions(mentions1, mentions2),
misskeyVisibleIds = parseStringArray(src.jsonArray("visibleUserIds")),
muted = false,
myRenoteId = myRenoteId,
parser = parser,
pinned = parser.pinned,
// quote_id 下記
profile_emojis = profile_emojis,
quote_muted = src.boolean("quote_muted") ?: false,
// reactionSet 下記
readerApDomain = parser.apDomain,
reblog = reblog,
// reblogParent
reblogged = reblogged,
reblogs_count = src.long("renoteCount") ?: 0L,
replies_count = src.long("repliesCount") ?: 0L,
reply = reply,
sensitive = sensitive,
serviceType = parser.serviceType,
spoiler_text = spoiler_text,
tags = parseMisskeyTags(src.jsonArray("tags")),
time_created_at = parseTime(created_at),
time_deleted_at = parseTime(deletedAt),
// time_edited_at
uri = uri,
url = url,
viaMobile = src.optBoolean("viaMobile"),
application = parseItem(src.jsonObject("app")) {
TootApplication(parser, it)
},
reactionSet = TootReactionSet.parseMisskey(
src.jsonObject("reactions") ?: src.jsonObject("reactionCounts"),
src.string("myReaction")
),
quote_id = when {
isQuoteToot -> reblog?.id
else -> null
},
visibility = TootVisibility.parseMisskey(
src.string("visibility"),
localOnly
) ?: TootVisibility.Unknown,
).apply {
// contentを読んだ後にアンケートのデコード
enquete = TootPolls.parse(
parser,
TootPollsType.Misskey,
this,
media_attachments,
src.jsonObject("poll"),
)
}
}
private fun statusNoteStock(parser: TootParser, src: JsonObject): TootStatus {
src["_fromStream"] = parser.fromStream
val apTag = APTag(parser, src.jsonArray("tag"))
val who = parser.account(src.jsonObject("account"))
?: error("missing account")
val accountRef = TootAccountRef.tootAccountRef(parser, who)
val account = accountRef.get()
val uri = src.string("id") ?: error("missing uri")
val url = src.string("url") ?: uri
val quote = when {
!parser.decodeQuote -> null
else -> try {
parser.decodeQuote = false
parser.status(src.jsonObject("quote"))
} finally {
parser.decodeQuote = true
}
}
val quote_id = quote?.id ?: EntityId.mayNull(src.string("quote_id"))
val apAttachment = APAttachment(src.jsonArray("attachment"))
val media_attachments = apAttachment.mediaAttachments.notEmpty()
val custom_emojis = apTag.emojiList.notEmpty()
val profile_emojis = apTag.profileEmojiList.notEmpty()
val created_at = src.string("published")
val mentions = apTag.mentions
val options1 = DecodeOptions(
parser.context,
parser.linkHelper,
short = true,
decodeEmoji = true,
emojiMapCustom = custom_emojis,
emojiMapProfile = profile_emojis,
attachmentList = media_attachments,
highlightTrie = parser.highlightTrie,
mentions = mentions,
authorDomain = account,
unwrapEmojiImageTag = true, // notestockはカスタム絵文字がimageタグになってる
)
// ハイライト検出のためにDecodeOptionsを作り直す
val options2 = DecodeOptions(
parser.context,
emojiMapCustom = custom_emojis,
emojiMapProfile = profile_emojis,
highlightTrie = parser.highlightTrie,
mentions = mentions,
authorDomain = account,
unwrapEmojiImageTag = true, // notestockはカスタム絵文字がimageタグになってる
)
val content = src.string("content")
val decoded_content = options1.decodeHTML(content)
var highlightSound = options1.highlightSound
var highlightSpeech = options1.highlightSpeech
var highlightAny = options1.highlightAny
val summaryRaw = (src.string("summary") ?: "").cleanCW()
val spoiler_text = when {
summaryRaw.isEmpty() -> "" // CWなし
summaryRaw.isBlank() -> parser.context.getString(R.string.blank_cw)
else -> summaryRaw
}
val decoded_spoiler_text = options2.decodeEmoji(spoiler_text)
if (highlightSound == null) highlightSound = options2.highlightSound
if (highlightSpeech == null) highlightSpeech = options2.highlightSpeech
if (highlightAny == null) highlightAny = options2.highlightAny
return TootStatus(
accountRef = accountRef,
application = null,
// bookmarked
card = quote?.let { TootCard.tootCard(parser, it) },
// conversationSummary
// conversation_main
content = content,
created_at = created_at,
custom_emojis = custom_emojis,
decoded_content = decoded_content,
// decoded_mentions 下記
decoded_spoiler_text = decoded_spoiler_text,
deletedAt = null,
// enquete 下記
// favourited
favourites_count = null,
// filteredV4
highlightAny = highlightAny,
highlightSound = highlightSound,
highlightSpeech = highlightSpeech,
id = findStatusIdFromUri(uri, url) ?: EntityId.DEFAULT,
in_reply_to_account_id = null,
in_reply_to_id = null,
// isFeatured
// isPromoted
//
isQuoteToot = quote_id != null,
// localOnly
json = src,
language = null,
media_attachments = media_attachments,
mentions = mentions,
misskeyVisibleIds = null,
muted = false,
// myRenoteId
parser = parser,
pinned = parser.pinned || src.optBoolean("pinned"),
profile_emojis = profile_emojis,
quote_id = quote_id,
quote_muted = src.boolean("quote_muted") ?: false,
// reactionSet
readerApDomain = null,
reblog = null,
// reblogParent
// reblogged
reblogs_count = null,
replies_count = null,
reply = null,
sensitive = src.optBoolean("sensitive"),
serviceType = parser.serviceType,
spoiler_text = spoiler_text,
tags = apTag.hashtags,
time_created_at = parseTime(created_at),
time_deleted_at = 0L,
uri = uri,
url = url,
// viaMobile
visibility = when (src.jsonArray("to")
?.any { it == "https://www.w3.org/ns/activitystreams#Public" }) {
true -> TootVisibility.Public
else -> TootVisibility.UnlistedHome
},
).apply {
decoded_mentions =
HTMLDecoder.decodeMentions(parser, this)
?: EMPTY_SPANNABLE
enquete = (src.jsonArray("oneOf") ?: src.jsonArray("anyOf"))?.let {
try {
TootPolls.tootPolls(
TootPollsType.Notestock,
parser,
this,
media_attachments,
src,
it
)
} catch (ex: Throwable) {
log.e(ex, "TootStatus ctor failed. enquete (NoteStock)")
null
}
}
}
}
private fun statusMastodon(parser: TootParser, src: JsonObject): TootStatus {
src["_fromStream"] = parser.fromStream
val url = src.string("url") // ブースト等では頻繁にnullになる
val created_at = src.string("created_at")
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく
val custom_emojis = parseMapOrNull(src.jsonArray("emojis"),CustomEmoji::decodeMastodon)
val profile_emojis = when (val o = src["profile_emojis"]) {
is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) }
is JsonObject -> parseProfileEmoji2(o) { j, k -> NicoProfileEmoji(j, k) }
else -> null
}
val mentions = parseListOrNull(src.jsonArray("mentions")) { TootMention(it) }
val who = parser.account(src.jsonObject("account"))
?: error("missing account")
val accountRef = TootAccountRef.tootAccountRef(parser, who)
val account = accountRef.get()
val readerApDomain: Host?
val id: EntityId
val uri: String
var reblogged = false
var favourited = false
var bookmarked = false
val time_created_at: Long
var media_attachments: ArrayList<TootAttachmentLike>? = null
val visibility: TootVisibility
val sensitive: Boolean
var filteredV4: List<TootFilterResult>? = null
when (parser.serviceType) {
ServiceType.MASTODON -> {
readerApDomain = parser.apDomain
id = EntityId.mayDefault(src.string("id"))
uri = src.string("uri") ?: error("missing uri")
reblogged = src.optBoolean("reblogged")
favourited = src.optBoolean("favourited")
bookmarked = src.optBoolean("bookmarked")
time_created_at = parseTime(created_at)
media_attachments =
parseListOrNull(src.jsonArray("media_attachments")) {
tootAttachment(parser, it)
}
val visibilityString = when {
src.boolean("limited") == true -> "limited"
else -> src.string("visibility")
}
visibility = TootVisibility.parseMastodon(visibilityString)
?: TootVisibility.Unknown
sensitive = src.optBoolean("sensitive")
filteredV4 = TootFilterResult.parseList(src.jsonArray("filtered"))
}
ServiceType.TOOTSEARCH -> {
readerApDomain = null
// 投稿元タンスでのIDを調べる。失敗するかもしれない
// XXX: Pleromaだとダメそうな印象
uri = src.string("uri") ?: error("missing uri")
id = findStatusIdFromUri(uri, url) ?: EntityId.DEFAULT
time_created_at = parseTime(created_at)
media_attachments = parseList(src.jsonArray("media_attachments")) {
tootAttachment(parser, it)
}
visibility = TootVisibility.Public
sensitive = src.optBoolean("sensitive")
}
ServiceType.MSP -> {
readerApDomain = parser.apDomain
// MSPのデータはLTLから呼んだものなので、常に投稿元タンスでのidが得られる
id = EntityId.mayDefault(src.string("id"))
// MSPだとuriは提供されない。LTL限定なのでURL的なものを作れるはず
uri =
"https://${account.apiHost}/users/${who.username}/statuses/$id"
time_created_at = parseTimeMSP(created_at)
media_attachments =
TootAttachmentMSP.parseList(src.jsonArray("media_attachments"))
visibility = TootVisibility.Public
sensitive = src.optInt("sensitive", 0) != 0
}
ServiceType.MISSKEY, ServiceType.NOTESTOCK -> error("will not happen")
}
val quote = when {
!parser.decodeQuote -> null
else -> try {
parser.decodeQuote = false
parser.status(src.jsonObject("quote"))
} finally {
parser.decodeQuote = true
}
}
val quote_id = quote?.id ?: EntityId.mayNull(src.string("quote_id"))
val isQuoteToot = quote_id != null
val quote_muted = src.boolean("quote_muted") ?: false
// Pinned TL を取得した時にreblogが登場することはないので、reblogについてpinned 状態を気にする必要はない
// Hostdon QT と通常のreblogが同時に出ることはないので、quoteが既出ならreblogのjsonデータは見ない
val reblog = quote ?: parser.status(src.jsonObject("reblog"))
val removeQt = false
// content
val content = src.string("content")?.let { sv ->
when {
removeQt -> {
log.d("removeQt? $sv")
val reQuoteTootRemover =
"""(?:\s|<br/>)*QT:\s*\[[^]]+]((?:</\w+>)*)\z""".toRegex()
sv.replace(reQuoteTootRemover) {
it.groupValues.elementAtOrNull(1) ?: ""
}.also { after ->
log.d("removeQt? after = $after")
}
}
else -> sv
}
}
val options1 = DecodeOptions(
parser.context,
parser.linkHelper,
short = true,
decodeEmoji = true,
emojiMapCustom = custom_emojis,
emojiMapProfile = profile_emojis,
attachmentList = media_attachments,
highlightTrie = parser.highlightTrie,
mentions = mentions,
authorDomain = account
)
val decoded_content = options1.decodeHTML(content)
var highlightSound = options1.highlightSound
var highlightSpeech = options1.highlightSpeech
var highlightAny = options1.highlightAny
val sv = (src.string("spoiler_text") ?: "").cleanCW()
val spoiler_text = when {
sv.isEmpty() -> "" // CWなし
sv.isBlank() -> parser.context.getString(R.string.blank_cw)
else -> sv
}
// ハイライト検出のためにDecodeOptionsを作り直す
val options2 = DecodeOptions(
parser.context,
emojiMapCustom = custom_emojis,
emojiMapProfile = profile_emojis,
highlightTrie = parser.highlightTrie,
mentions = mentions,
authorDomain = account
)
val decoded_spoiler_text = options2.decodeEmoji(spoiler_text)
if (highlightSound == null) highlightSound = options2.highlightSound
if (highlightSpeech == null) highlightSpeech = options2.highlightSpeech
if (highlightAny == null) highlightAny = options2.highlightAny
return TootStatus(
accountRef = accountRef,
bookmarked = bookmarked,
// card 下記
// conversationSummary
// conversation_main
content = content,
created_at = created_at,
custom_emojis = custom_emojis,
decoded_content = decoded_content,
// decoded_mentions
decoded_spoiler_text = decoded_spoiler_text,
deletedAt = null,
// enquete 下記
favourited = favourited,
favourites_count = src.long("favourites_count"),
filteredV4 = filteredV4,
highlightAny = highlightAny,
highlightSound = highlightSound,
highlightSpeech = highlightSpeech,
id = id,
in_reply_to_account_id = EntityId.mayNull(src.string("in_reply_to_account_id")),
in_reply_to_id = EntityId.mayNull(src.string("in_reply_to_id")),
// isFeatured
// isPromoted
isQuoteToot = isQuoteToot,
json = src,
language = src.string("language")?.notEmpty(),
// localOnly
media_attachments = media_attachments,
mentions = mentions,
misskeyVisibleIds = null,
muted = src.optBoolean("muted"),
// myRenoteId
parser = parser,
pinned = parser.pinned || src.optBoolean("pinned"),
profile_emojis = profile_emojis,
quote_id = quote_id,
quote_muted = quote_muted,
// reactionSet 下記
readerApDomain = readerApDomain,
reblog = reblog,
reblogged = reblogged,
reblogs_count = src.long("reblogs_count"),
replies_count = src.long("replies_count"),
reply = null,
sensitive = sensitive,
serviceType = parser.serviceType,
spoiler_text = spoiler_text,
tags = TootTag.parseListOrNull(parser, src.jsonArray("tags")),
time_created_at = time_created_at,
time_deleted_at = 0L,
time_edited_at = parseTime(src.string("edited_at")),
uri = uri,
url = url,
// viaMobile
visibility = visibility,
reactionSet = TootReactionSet.parseFedibird(
src.jsonArray("emoji_reactions")
?: src.jsonObject("pleroma")?.jsonArray("emoji_reactions")
),
application = parseItem(src.jsonObject("application")) {
TootApplication(parser, it)
},
).apply {
enquete = try {
src.string("enquete")?.notEmpty()?.let {
TootPolls.tootPolls(
TootPollsType.FriendsNico,
parser,
this,
media_attachments,
it.decodeJsonObject(),
)
} ?: src.jsonObject("poll")?.let {
TootPolls.tootPolls(
TootPollsType.Mastodon,
parser,
this,
media_attachments,
it,
)
}
} catch (ex: Throwable) {
log.e(ex, "TootStatus ctor failed. enquete")
null
}
// 2.6.0からステータスにもカード情報が含まれる
card = parseItem(src.jsonObject("card")) { TootCard.tootCard(it) }
if (card == null && quote != null) {
// 引用Renoteにプレビューカードをでっちあげる
card = TootCard.tootCard(parser, quote)
// content中のQTの表現が四角括弧の有無とか色々あるみたいだし
// 選択してコピーのことを考えたらむしろ削らない方が良い気がしてきた
// removeQt = ! PrefB.bpDontShowPreviewCard(Pref.pref(parser.context))
}
decoded_mentions =
HTMLDecoder.decodeMentions(parser, this)
?: EMPTY_SPANNABLE
}
}
fun tootStatus(parser: TootParser, src: JsonObject): TootStatus =
when (parser.serviceType) {
ServiceType.MISSKEY -> statusMisskey(parser, src)
ServiceType.NOTESTOCK -> statusNoteStock(parser, src)
else -> statusMastodon(parser, src)
}
fun updateMuteData(force: Boolean = false) {
val now = SystemClock.elapsedRealtime()
if (force || muted_app == null || muted_word == null ||
now >= timeMuteData.get() + MUTE_DATA_EXPIRE
) {
timeMuteData.set(now)
muted_app = daoMutedApp.nameSet()
muted_word = daoMutedWord.nameSet()
favMuteSet = daoFavMute.acctSet()
}
}
const val LANGUAGE_CODE_UNKNOWN = "unknown"
const val LANGUAGE_CODE_DEFAULT = "default"
val EMPTY_SPANNABLE = SpannableString("")
// val reHostIdn = TootAccount.reHostIdn
// OStatus
private val reTootUriOS = """tag:([^,]*),[^:]*:objectId=([^:?#/\s]+):objectType=Status"""
.asciiPattern(Pattern.CASE_INSENSITIVE)
// ActivityPub 1
private val reTootUriAP1 = """https?://([^/]+)/users/\w+/statuses/([^?#/\s]+)"""
.asciiPattern()
// ActivityPub 2
private val reTootUriAP2 = """https?://([^/]+)/@\w+/([^?#/\s]+)"""
.asciiPattern()
// 公開ステータスページのURL マストドン
private val reStatusPage = """\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)(?:\z|[?#])"""
.asciiPattern()
// fedibird ステータスの参照のURL
private val reStatusWithReference =
"""\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)/references(?:\z|[?#])"""
.asciiPattern()
// 公開ステータスページのURL Misskey
internal val reStatusPageMisskey =
"""\Ahttps://([^/]+)/notes/([0-9a-f]{24}|[0-9a-z]{10})\b"""
.asciiPattern(Pattern.CASE_INSENSITIVE)
// PleromaのStatusのUri
private val reStatusPageObjects = """\Ahttps://([^/]+)/objects/([^?#/\s]+)(?:\z|[?#])"""
.asciiPattern()
// PleromaのStatusの公開ページ
private val reStatusPageNotice = """\Ahttps://([^/]+)/notice/([^?#/\s]+)(?:\z|[?#])"""
.asciiPattern()
// PixelfedのStatusの公開ページ
// https://pixelfed.tokyo/p/tateisu/84169185147621376
private val reStatusPagePixelfed =
"""\Ahttps://([^/]+)/p/([A-Za-z0-9_]+)/([^?#/\s]+)(?:\z|[?#])"""
.asciiPattern()
// returns null or pair( status_id, host ,url )
fun String.findStatusIdFromUrl(): FindStatusIdFromUrlResult? {
// https://fedibird.com/@noellabo/108730353756004469/references
var m = reStatusWithReference.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(
EntityId(m.groupEx(3)!!),
m.groupEx(1)!!,
this,
isReference = true,
)
}
// https://mastodon.juggler.jp/@SubwayTooter/(status_id)
m = reStatusPage.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(EntityId(m.groupEx(3)!!), m.groupEx(1)!!, this)
}
// https://misskey.xyz/notes/(id)
m = reStatusPageMisskey.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(EntityId(m.groupEx(2)!!), m.groupEx(1)!!, this)
}
// https://misskey.xyz/objects/(id)
m = reStatusPageObjects.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(
null, // ステータスIDではないのでどのタンスで開くにせよ検索APIを通すことになる
m.groupEx(1)!!,
this
)
}
// https://pl.telteltel.com/notice/9fGFPu4LAgbrTby0xc
m = reStatusPageNotice.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(
EntityId(m.groupEx(2)!!),
m.groupEx(1)!!,
this
)
}
m = reStatusPagePixelfed.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(
EntityId(m.groupEx(3)!!),
m.groupEx(1)!!,
this
)
}
return null
}
private val reDate = """\A(\d+\D+\d+\D+\d+)\z"""
.asciiRegex()
private val reTime = """\A(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)(?:\D+(\d+))?"""
.asciiRegex()
private val reTimeWithZone =
"""\A(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)(?:\D+(\d+))?(?:(Z|[+-])(\d+):?(\d*))?"""
.asciiRegex()
private val reMSPTime = """\A(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)"""
.asciiRegex()
private val tzUtc = TimeZone.getTimeZone("UTC")
@Throws(Throwable::class)
fun parseTimeUtc(strTime: String): Long {
val gv = reTime.find(strTime)?.groupValues
?: error("time format not match.")
return GregorianCalendar.getInstance()
.apply {
timeZone = tzUtc
set(
gv.elementAtOrNull(1)?.toIntOrNull() ?: 1,
(gv.elementAtOrNull(2)?.toIntOrNull() ?: 1) - 1,
gv.elementAtOrNull(3)?.toIntOrNull() ?: 1,
gv.elementAtOrNull(4)?.toIntOrNull() ?: 0,
gv.elementAtOrNull(5)?.toIntOrNull() ?: 0,
gv.elementAtOrNull(6)?.toIntOrNull() ?: 0,
)
set(Calendar.MILLISECOND, (gv.elementAtOrNull(7)?.toIntOrNull() ?: 0))
}.timeInMillis
}
// ISO-8601の Z,[+-]HH:mm,[+-]HHmm,[+-]HH 部分を解釈してオフセット(ミリ秒)を返す
@Throws(Throwable::class)
fun parseTimeZoneOffset(sign: String?, hArg: String?, mArg: String?): Long {
val minutes = when {
sign == null || sign == "Z" || hArg == null || hArg.isEmpty() -> {
// Z or missing hour part
0
}
mArg != null && mArg.isNotEmpty() -> {
// HH:mm or H:m
val h = hArg.toInt()
val m = mArg.toInt()
h * 60 + m
}
hArg.length >= 3 -> {
// HHmm or Hmm
val h = hArg.substring(0, hArg.length - 2).toInt()
val m = hArg.substring(hArg.length - 2).toInt()
h * 60 + m
}
else -> {
// HH or H
val h = hArg.toInt()
h * 60
}
}
return minutes.toLong() * 60000L * (if (sign == "-") -1L else 1L)
}
@Throws(Throwable::class)
fun parseTimeIso8601(strTime: String): Long {
val gv = reTimeWithZone.find(strTime)?.groupValues
?: error("time format not match.")
return GregorianCalendar.getInstance()
.apply {
timeZone = tzUtc
set(
gv.elementAtOrNull(1)?.toIntOrNull() ?: 1,
(gv.elementAtOrNull(2)?.toIntOrNull() ?: 1) - 1,
gv.elementAtOrNull(3)?.toIntOrNull() ?: 1,
gv.elementAtOrNull(4)?.toIntOrNull() ?: 0,
gv.elementAtOrNull(5)?.toIntOrNull() ?: 0,
gv.elementAtOrNull(6)?.toIntOrNull() ?: 0,
)
set(Calendar.MILLISECOND, (gv.elementAtOrNull(7)?.toIntOrNull() ?: 0))
}.timeInMillis -
parseTimeZoneOffset(
gv.elementAtOrNull(8),
gv.elementAtOrNull(9),
gv.elementAtOrNull(10),
)
}
// 時刻を解釈してエポック秒(ミリ単位)を返す
// 解釈に失敗すると0Lを返す
fun parseTime(strTime: String?): Long {
if (strTime.isNullOrBlank()) return 0L
// last_status_at などでは YYYY-MM-DD になることがある
reDate.find(strTime)?.groupValues?.let { gv ->
return parseTime("${gv[1]}T00:00:00.000Z")
}
// タイムゾーン指定を考慮したパース
try {
return parseTimeIso8601(strTime)
} catch (ex: Throwable) {
log.w(ex, "parseTime2 failed. $strTime")
}
// 古い処理にフォールバック
try {
return parseTimeUtc(strTime)
} catch (ex: Throwable) {
log.w(ex, "parseTime1 failed. $strTime")
}
return 0L
}
private fun parseTimeMSP(strTime: String?): Long {
if (strTime?.isNotBlank() != true) return 0L
try {
val gv = reMSPTime.find(strTime)?.groupValues
?: error("time format not match.")
return GregorianCalendar.getInstance()
.apply {
timeZone = tzUtc
set(
gv.elementAtOrNull(1)?.toIntOrNull() ?: 1,
(gv.elementAtOrNull(2)?.toIntOrNull() ?: 1) - 1,
gv.elementAtOrNull(3)?.toIntOrNull() ?: 1,
gv.elementAtOrNull(4)?.toIntOrNull() ?: 0,
gv.elementAtOrNull(5)?.toIntOrNull() ?: 0,
gv.elementAtOrNull(6)?.toIntOrNull() ?: 0,
)
set(Calendar.MILLISECOND, 500)
}.timeInMillis
} catch (ex: Throwable) {
log.w(ex, "parseTimeMSP failed. src=$strTime")
}
return 0L
}
@SuppressLint("SimpleDateFormat")
val dateFormatFull = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
@SuppressLint("SimpleDateFormat")
val date_format2 = SimpleDateFormat("yyyy-MM-dd")
fun formatTime(
context: Context,
t: Long,
bAllowRelative: Boolean,
onlyDate: Boolean = false,
): String {
val now = System.currentTimeMillis()
var delta = now - t
@StringRes val phraseId = when {
delta >= 0 -> R.string.relative_time_phrase_past
else -> R.string.relative_time_phrase_future
}
fun f(v: Long, unit1: Int, units: Int): String {
val vi = v.toInt()
return context.getString(
phraseId,
vi,
context.getString(if (vi <= 1) unit1 else units)
)
}
if (onlyDate) return when {
delta < 40 * 86400000L -> f(
delta / 86400000L,
R.string.relative_time_unit_day1,
R.string.relative_time_unit_days
)
else ->
formatDate(t, date_format2, omitZeroSecond = false, omitYear = true)
}
if (bAllowRelative && PrefB.bpRelativeTimestamp.value) {
delta = abs(delta)
when {
delta < 1000L -> return context.getString(R.string.time_within_second)
delta < 60000L -> return f(
delta / 1000L,
R.string.relative_time_unit_second1,
R.string.relative_time_unit_seconds
)
delta < 3600000L -> return f(
delta / 60000L,
R.string.relative_time_unit_minute1,
R.string.relative_time_unit_minutes
)
delta < 86400000L -> return f(
delta / 3600000L,
R.string.relative_time_unit_hour1,
R.string.relative_time_unit_hours
)
delta < 40 * 86400000L -> return f(
delta / 86400000L,
R.string.relative_time_unit_day1,
R.string.relative_time_unit_days
)
}
// fall back to absolute time
}
return formatDate(t, dateFormatFull, omitZeroSecond = false, omitYear = false)
}
// 告知の開始/終了日付
private fun formatDate(
t: Long,
format: SimpleDateFormat,
omitZeroSecond: Boolean,
omitYear: Boolean,
): String {
var dateTarget = format.format(Date(t))
// 秒の部分を省略する
if (omitZeroSecond && dateTarget.endsWith(":00")) {
dateTarget = dateTarget.substring(0, dateTarget.length - 3)
}
// 年の部分が現在と同じなら省略する
if (omitYear) {
val dateNow = format.format(Date())
val delm = dateNow.indexOf('-')
if (delm != -1 &&
dateNow.substring(0, delm + 1) == dateTarget.substring(0, delm + 1)
) {
dateTarget = dateTarget.substring(delm + 1)
}
}
return dateTarget
}
fun formatTimeRange(start: Long, end: Long, allDay: Boolean): Pair<String, String> {
val strStart = when {
start <= 0L -> ""
allDay -> formatDate(start, date_format2, omitZeroSecond = false, omitYear = true)
else -> formatDate(start, dateFormatFull, omitZeroSecond = true, omitYear = true)
}
val strEnd = when {
end <= 0L -> ""
allDay -> formatDate(end, date_format2, omitZeroSecond = false, omitYear = true)
else -> formatDate(end, dateFormatFull, omitZeroSecond = true, omitYear = true)
}
// 終了日は先頭と同じ部分を省略する
var skip = 0
for (i in 0 until min(strStart.length, strEnd.length)) {
val c = strStart[i]
if (c != strEnd[i]) break
if (c.isDigit()) continue
skip = i + 1
if (c == ' ') break // 時間以降は省略しない
}
return Pair(strStart, strEnd.substring(skip, strEnd.length))
}
fun parseStringArray(src: JsonArray?): ArrayList<String>? {
var rv: ArrayList<String>? = null
if (src != null) {
for (i in src.indices) {
val s = src.string(i)
if (s?.isNotEmpty() == true) {
if (rv == null) rv = ArrayList()
rv.add(s)
}
}
}
return rv
}
private fun parseMisskeyTags(src: JsonArray?): ArrayList<TootTag>? {
var rv: ArrayList<TootTag>? = null
if (src != null) {
for (i in src.indices) {
val sv = src.string(i)
if (sv?.isNotEmpty() == true) {
if (rv == null) rv = ArrayList()
rv.add(TootTag(name = sv))
}
}
}
return rv
}
fun validStatusId(src: EntityId?): EntityId? =
when {
src == null -> null
src == EntityId.DEFAULT -> null
src.toString().startsWith("-") -> null
else -> src
}
private fun String.cleanCW() =
CharacterGroup.reWhitespace.matcher(this).replaceAll(" ").sanitizeBDI()
/* 空欄かどうかがCW判定条件に影響するので、trimしてはいけない */
// 投稿元タンスでのステータスIDを調べる
fun findStatusIdFromUri(
uri: String?,
url: String?,
): EntityId? {
try {
if (uri?.isNotEmpty() == true) {
// https://friends.nico/users/(who)/statuses/(status_id)
var m = reTootUriAP1.matcher(uri)
if (m.find()) return EntityId(m.groupEx(2)!!)
// https://server/@user/(status_id)
m = reTootUriAP2.matcher(uri)
if (m.find()) return EntityId(m.groupEx(2)!!)
// https://misskey.xyz/notes/5b802367744b650030a13640
m = reStatusPageMisskey.matcher(uri)
if (m.find()) return EntityId(m.groupEx(2)!!)
// https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43
// tootsearch中の投稿からIDを読めるようにしたい
// しかしこのURL中のuuidはステータスIDではないので、無意味
// m = reObjects.matcher(uri)
// if(m.find()) return EntityId(m.groupEx(2))
// https://pl.telteltel.com/notice/9fGFPu4LAgbrTby0xc
m = reStatusPageNotice.matcher(uri)
if (m.find()) return EntityId(m.groupEx(2)!!)
// tag:mstdn.osaka,2017-12-19:objectId=5672321:objectType=Status
m = reTootUriOS.matcher(uri)
if (m.find()) return EntityId(m.groupEx(2)!!)
log.w("findStatusIdFromUri: unsupported uri. $uri")
}
if (url?.isNotEmpty() == true) {
// https://friends.nico/users/(who)/statuses/(status_id)
var m = reTootUriAP1.matcher(url)
if (m.find()) return EntityId(m.groupEx(2)!!)
// https://friends.nico/@(who)/(status_id)
m = reTootUriAP2.matcher(url)
if (m.find()) return EntityId(m.groupEx(2)!!)
// https://misskey.xyz/notes/5b802367744b650030a13640
m = reStatusPageMisskey.matcher(url)
if (m.find()) return EntityId(m.groupEx(2)!!)
// https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43
// tootsearch中の投稿からIDを読めるようにしたい
// しかしこのURL中のuuidはステータスIDではないので、無意味
// m = reObjects.matcher(url)
// if(m.find()) return EntityId(m.groupEx(2))
// https://pl.telteltel.com/notice/9fGFPu4LAgbrTby0xc
m = reStatusPageNotice.matcher(url)
if (m.find()) return EntityId(m.groupEx(2)!!)
log.w("findStatusIdFromUri: unsupported url. $url")
}
} catch (ex: Throwable) {
log.e(ex, "can't parse status from: $uri,$url")
}
return null
}
private val supplyEditHistoryKeys = arrayOf(
"id",
"uri",
"url",
"visibility",
)
// 編集履歴のデータはTootStatusとしては不足があるので、srcを元に補う
fun supplyEditHistory(array: JsonArray?, src: JsonObject?) {
src ?: return
array?.objectList()?.forEach {
for (key in supplyEditHistoryKeys) {
if (it.containsKey(key)) continue
it[key] = src[key]
}
}
}
}
}