Commit 452923d2 authored by augier's avatar augier

Solves #74: Ability to add a new feed

parent 06fc53c2
Pipeline #2250 passed with stage
......@@ -7,6 +7,7 @@
* Implements [#49](https://git.feneas.org/christophehenry/freshrss-android/issues/49): Emotionnal design ([!35](https://git.feneas.org/christophehenry/freshrss-android/merge_requests/35))
* Implements [#66](https://git.feneas.org/christophehenry/freshrss-android/issues/66): French translations (Fipaddict, [!38](https://git.feneas.org/christophehenry/freshrss-android/merge_requests/38))
* Implements [#34](https://git.feneas.org/christophehenry/freshrss-android/issues/34): Implements feeds retrieval scheduling in settings as well as hability to disable it ([!53](https://git.feneas.org/christophehenry/freshrss-android/merge_requests/53))
* Implements [#74](https://git.feneas.org/christophehenry/freshrss-android/issues/74): Ability to add a new feed ([!54](https://git.feneas.org/christophehenry/freshrss-android/merge_requests/54))
## Bug fixes
......
......@@ -67,9 +67,10 @@ pipeline {
}
}
}
stage("Static analysis") {
stage("Lint") {
steps {
updateGitlabCommitStatus name: "Static analysis", state: "running"
updateGitlabCommitStatus name: "Lint", state: "running"
sh "./gradlew spotlessApply lint"
sh "./gradlew lintRelease --continue"
androidLint pattern: "**/lint-results-*.xml"
publishHTML([
......@@ -85,10 +86,10 @@ pipeline {
post {
failure {
updateGitlabCommitStatus name: "Static analysis", state: "failed"
updateGitlabCommitStatus name: "Lint", state: "failed"
}
success {
updateGitlabCommitStatus name: "Static analysis", state: "success"
updateGitlabCommitStatus name: "Lint", state: "success"
}
}
}
......
......@@ -8,6 +8,8 @@ Finally, an Android application specifically dedicated to the awesome [FreshRSS
![subscription articles](./fastlane/metadata/android/en-US/images/phoneScreenshots/5-subscription-articles.png) ![article details](./fastlane/metadata/android/en-US/images/phoneScreenshots/6-article-detail.png)
![add feed panel](./fastlane/metadata/android/en-US/images/phoneScreenshots/11-add-feed.png)
## Contact & troubleshoot
If you need help for contributing or using the application, you can contact us by [joining us on Framateam](https://framateam.org/signup_user_complete/?id=e2680d3e3128b9fac8fdb3003b0024ee) or
......
......@@ -12,7 +12,9 @@ import androidx.navigation.Navigation
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import fr.chenry.android.freshrss.*
import fr.chenry.android.freshrss.components.navigationdrawer.AddSubscriptionDialog
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.utils.e
import fr.chenry.android.freshrss.utils.whenNotNull
import kotlinx.android.synthetic.main.activity_main.*
......@@ -57,7 +59,15 @@ class MainActivity: AppCompatActivity() {
return super.onOptionsItemSelected(item)
}
fun onSettingItemClick(item: MenuItem): Boolean {
fun onAddSubscriptionClick(menuItem: MenuItem): Boolean {
drawerLayout.closeDrawers()
AddSubscriptionDialog{
Store.postAddSubscription(it).fail(this::e)
}.show(main_activity_host_fragment!!.childFragmentManager, AddSubscriptionDialog::class.java.canonicalName)
return true
}
fun onSettingsItemClick(menuItem: MenuItem): Boolean {
drawerLayout.closeDrawers()
navigation.navigate(MainNavDirections.actionGlobalSettingsFragment())
return true
......
package fr.chenry.android.freshrss.components.navigationdrawer
import android.app.Dialog
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.utils.*
import java.net.URL
class AddSubscriptionDialog(private val callback: (String) -> Unit): DialogFragment() {
private val clipboard by lazy {
FreshRSSApplication.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
}
private val dialogView by lazy {
requireActivity().layoutInflater.inflate(R.layout.fragment_add_subscription_dialog, null)
}
private val editText get() = dialogView.findViewById<EditText>(R.id.fragment_add_subscription_dialog_text_field)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity!!.let {
val builder = AlertDialog.Builder(it)
val result = builder.setView(dialogView)
.setPositiveButton(android.R.string.ok) {_, _ ->
callback(tryProcessYoutubeUrl(editText.text.toString()))
}
.setNegativeButton(android.R.string.cancel) {dialog, _ -> dialog.cancel()}
.create()
editText?.let {self ->
if(clipboard.hasPrimaryClip()) {
val item = clipboard.primaryClip!!.getItemAt(0)
val uris = when {
item.text != null -> item.text.toString().extractURLs()
item.uri != null -> listOf(item.uri)
else -> listOf()
}
if(uris.isNotEmpty()) {
self.setText(uris[0].toString(), TextView.BufferType.EDITABLE)
self.selectAll()
}
}
}
result
}
}
companion object {
/**
* Will try to detect a Youtube's user profile or playlist URL and return the correct
* Youtube's RSS feed for the user or playlist.
*
* If provided URL is not a Youtube or invidio.us URL, the provided URL will be returned as is.
*
* Examples :
*
* >>>> tryProcessYoutubeUrl("https://www.youtube.com/channel/UCNHbHR2KOc851Kkb_TJ2Orw")
* "https://www.youtube.com/feeds/videos.xml?channel_id=UCNHbHR2KOc851Kkb_TJ2Orw"
*
* >>>> tryProcessYoutubeUrl("https://www.youtube.com/user/CosmicSoundwaves/videos")
* "https://www.youtube.com/feeds/videos.xml?user=CosmicSoundwaves"
*
* >>>> tryProcessYoutubeUrl("https://www.youtube.com/watch?v=2w7hAuA9dq8&list=PLygWqO36YZG039tHkjo0v4O-1vl7l74or")
* "https://www.youtube.com/feeds/videos.xml?playlist_id=PLygWqO36YZG039tHkjo0v4O-1vl7l74or"
*/
fun tryProcessYoutubeUrl(url: String): String {
val ytUrl = Try {URL(url)}.getOrNull() ?: return url
val path = ytUrl.path!!.addTrailingSlash()
val domain = ytUrl.host!!.split(".").takeLast(2).joinToString(".")
if(!domain.startsWith("youtube.") && ytUrl.host != "invidio.us") return url
val channelRegexGroups = "^/channel/([^/]+)/.*$".toRegex().matchEntire(path)?.groupValues ?: listOf()
if(channelRegexGroups.size == 2)
return "https://www.youtube.com/feeds/videos.xml?channel_id=${channelRegexGroups[1]}"
val userRegexGroups = "^/user/([^/]+)/.*$".toRegex().matchEntire(path)?.groupValues ?: listOf()
if(userRegexGroups.size == 2)
return "https://www.youtube.com/feeds/videos.xml?user=${userRegexGroups[1]}"
val queryParameters = ytUrl.queryParameters()
if("list" in queryParameters.keys)
return "https://www.youtube.com/feeds/videos.xml?playlist_id=${queryParameters["list"]}"
return url
}
}
}
package fr.chenry.android.freshrss.components.settings
package fr.chenry.android.freshrss.components.navigationdrawer
import android.os.Bundle
import androidx.preference.*
......
......@@ -12,18 +12,22 @@ import fr.chenry.android.freshrss.utils.whenNotNull
import fr.chenry.android.freshrss.utils.whenNull
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.successUi
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
object Store {
var debugMode = false
val totalUnreadCount = MutableLiveData<Int>()
val subscriptionsSection = MutableLiveData<SubscriptionSection>().apply { this.value = SubscriptionSection.ALL }
val tags = MutableLiveData<List<String>>().apply { this.value = listOf() }
val subscriptionsSection =
MutableLiveData<SubscriptionSection>().apply {this.value = SubscriptionSection.ALL}
val tags = MutableLiveData<List<String>>().apply {this.value = listOf()}
val refreshingPromise = MutableLiveData<Promise<Unit, Exception>>()
val account by lazy {
MutableLiveData<Account>().apply {
accountLiveData.value?.firstOrNull().whenNull {value = VoidAccount}.whenNotNull {value = it}
accountLiveData.value?.firstOrNull().whenNull {value = VoidAccount}
.whenNotNull {value = it}
accountLiveData.observeForever {self ->
self.firstOrNull().whenNull {value = VoidAccount}.whenNotNull {value = it}
}
......@@ -40,34 +44,34 @@ object Store {
val account = flowable.blockingFirst().firstOrNull() ?: VoidAccount
api = Api(account)
accountLiveData = FreshRSSApplication.database.getAccount().toLiveData().apply {
observeForever { if (it.isNotEmpty()) api = Api(it.first()) }
observeForever {if(it.isNotEmpty()) api = Api(it.first())}
}
}
fun login(instance: String, user: String, password: String): Promise<Unit, Exception> =
Api.login(instance, user, password) then { FreshRSSApplication.database.insertAccount(it) }
Api.login(instance, user, password) then {FreshRSSApplication.database.insertAccount(it)}
fun ensureToken(): Promise<Unit, Exception> {
if (!api.account.isWriteTokenExpired) return Promise.ofSuccess(Unit)
if(!api.account.isWriteTokenExpired) return Promise.ofSuccess(Unit)
return api.getWriteToken()
.success { api.account.writeToken = it }
.success {api.account.writeToken = it.trim()}
.toSuccessVoid()
}
fun getSubscriptions(): Promise<Unit, Exception> = api.getSubscriptions() bind {
val subscriptions = it.map { self -> Subscription.fromSubscriptionApiItem(self) }
val subscriptions = it.map {self -> Subscription.fromSubscriptionApiItem(self)}
val syncPromise = FreshRSSApplication.database.syncSubscriptions(subscriptions).always {
task {
FreshRSSApplication.database.let { db ->
FreshRSSApplication.database.let {db ->
db.getAllSubcriptionsWithImageToUpdate()
.blockingFirst()
.forEach { sub -> db.insertSubscriptionImage(sub.id, sub.fetchImage()) }
.forEach {sub -> db.insertSubscriptionImage(sub.id, sub.fetchImage())}
}
}
}
val categoriesPromise = task {
val subscriptionCategories = it.flatMap { self -> self.categories }.map(::fromApiItem)
val subscriptionCategories = it.flatMap {self -> self.categories}.map(::fromApiItem)
FreshRSSApplication.database.insertAllSubscriptionCategories(subscriptionCategories)
}
all(syncPromise, categoriesPromise, cancelOthersOnError = false).toSuccessVoid()
......@@ -77,27 +81,39 @@ object Store {
api.getUnreadCount() then {
totalUnreadCount.postValue(it.max)
it.unreadcounts
.forEach { self -> FreshRSSApplication.database.updateSubscriptionCount(self.id, self.count) }
.forEach {self ->
FreshRSSApplication.database.updateSubscriptionCount(
self.id,
self.count
)
}
}
fun getStreamContents(id: String): Promise<Unit, Exception> =
api.getStreamContents(id) bind {
val insertPromises = it.items.map { item ->
val insertPromises = it.items.map {item ->
task {
FreshRSSApplication.database.insertArticle(Article.fromContentItem(item))
FreshRSSApplication.database.updateSubscriptionNewestArticleDate(id, item.crawled)
FreshRSSApplication.database.updateSubscriptionNewestArticleDate(
id,
item.crawled
)
}
}
all(insertPromises, cancelOthersOnError = false).toSuccessVoid()
}
fun getTags(): Promise<Unit, Exception> = api.getTags() then { tags.postValue(it) }
fun getTags(): Promise<Unit, Exception> = api.getTags() then {tags.postValue(it)}
fun getUnreadItems(): Promise<Unit, Exception> =
api.getUnreadItems(lastFetchTimestamp) bind {
val promises = it.items.map { item ->
val promises = it.items.map {item ->
task {
FreshRSSApplication.database.upsertArticle(Article.fromContentItem(item).copy(readStatus = UNREAD))
FreshRSSApplication.database.upsertArticle(
Article.fromContentItem(item).copy(
readStatus = UNREAD
)
)
}
}
lastFetchTimestamp = System.currentTimeMillis() % 1000
......@@ -105,7 +121,7 @@ object Store {
}
fun postReadStatus(article: Article, readStatus: ReadStatus): Promise<Unit, Exception> {
if (onGoingPostRequests.contains(article.id)) return onGoingPostRequests[article.id]!!
if(onGoingPostRequests.contains(article.id)) return onGoingPostRequests[article.id]!!
return ensureToken() bind {
article.requestOnGoing = true
......@@ -115,7 +131,7 @@ object Store {
}.success {
val db = FreshRSSApplication.database
db.upsertArticle(article.copy(readStatus = readStatus))
when (readStatus) {
when(readStatus) {
READ -> {
db.decrementSubscriptionCount(article.streamId)
totalUnreadCount.postValue(max((totalUnreadCount.value ?: 0) - 1, 0))
......@@ -128,4 +144,34 @@ object Store {
}
}
}
fun postAddSubscription(url: String): Promise<Unit, Exception> =
ensureToken().bind {
val addSubscriptionPromise = api.postAddSubscription(url)
addSubscriptionPromise successUi {id ->
val deferred = deferred<Unit, Exception>()
fun addCallback(): Promise<Unit, Exception> = getSubscriptions() bind {
getUnreadCount()
} bind {
refreshingPromise.postValue(deferred.promise)
getStreamContents(id)
} bind {
getUnreadItems()
} alwaysUi { refreshingPromise.value = null }
refreshingPromise.value.whenNotNull {
it.always {
addCallback().success(deferred::resolve).fail(deferred::reject)
}
}.whenNull {
addCallback().success(deferred::resolve).fail(deferred::reject)
}
deferred.promise
}
addSubscriptionPromise.toSuccessVoid()
}
}
package fr.chenry.android.freshrss.store.api
import com.github.kittinunf.fuel.Fuel
import fr.chenry.android.freshrss.store.api.models.ContentItemsHandler
import fr.chenry.android.freshrss.store.api.models.SubscriptionApiItem
import fr.chenry.android.freshrss.store.api.models.SubscriptionsHandler
import fr.chenry.android.freshrss.store.api.models.TagsDeserializer
import fr.chenry.android.freshrss.store.api.models.UnreadCountsHandler
import fr.chenry.android.freshrss.store.database.models.Account
import fr.chenry.android.freshrss.store.database.models.ItemId
import fr.chenry.android.freshrss.store.database.models.ReadStatus
import fr.chenry.android.freshrss.utils.authentify
import fr.chenry.android.freshrss.utils.authorize
import fr.chenry.android.freshrss.utils.getJson
import fr.chenry.android.freshrss.utils.promise
import fr.chenry.android.freshrss.utils.promiseString
import fr.chenry.android.freshrss.utils.promiseWithSerializer
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.then
import nl.komponents.kovenant.toSuccessVoid
import fr.chenry.android.freshrss.store.api.models.*
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.utils.*
import nl.komponents.kovenant.*
class Api(val account: Account) {
private val endpoints = Endpoints(account.serverInstance)
......@@ -43,7 +29,7 @@ class Api(val account: Account) {
fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> =
Fuel
.getJson(endpoints.streamContentsEndpoint(id))
.getJson(endpoints.streamContentsEndpoint(id), listOf("n" to 1_000_000))
.authentify(this.account)
.promise()
......@@ -53,24 +39,17 @@ class Api(val account: Account) {
.authentify(this.account)
.promiseWithSerializer(TagsDeserializer)
fun getUnreadItems(
olderTimestamp: Long,
continuation: String? = null
): Promise<ContentItemsHandler, Exception> {
val params = mutableListOf("xt" to "user/-/state/com.google/read")
if (!continuation.isNullOrBlank()) params.add("c" to continuation)
fun getUnreadItems(olderTimestamp: Long): Promise<ContentItemsHandler, Exception> {
val params = mutableListOf(
"xt" to "user/-/state/com.google/read",
"n" to 1_000_000
)
if (olderTimestamp > 0) params.add("ot" to "$olderTimestamp")
return Fuel
.getJson(endpoints.unreadItemsEndpoint, params)
.authentify(account)
.promise<ContentItemsHandler>()
.bind { it1 ->
if (it1.continuation.isNotBlank() && it1.continuation != continuation) {
getUnreadItems(olderTimestamp, it1.continuation)
.then { it2 -> it2.copy(items = it1.items + it2.items) }
} else Promise.ofSuccess(it1)
}
.promise()
}
fun postReadStatus(itemId: ItemId, readStatus: ReadStatus): Promise<Unit, Exception> {
......@@ -86,6 +65,13 @@ class Api(val account: Account) {
.toSuccessVoid()
}
fun postAddSubscription(url: String): Promise<String, Exception> =
Fuel
.post(endpoints.subscriptionAdd(url))
.authentify(account)
.authorize(account)
.promiseWithSerializer(SubscritpionAddResponseDeserializer)
companion object {
fun login(instance: String, login: String, password: String): Promise<Account, Exception> {
val params = listOf(
......
package fr.chenry.android.freshrss.store.api
import java.net.URLEncoder
class Endpoints(private val base: String) {
val loginEndpoint = "${this.base}/accounts/ClientLogin"
val tokenEndpoint = "${this.base}/reader/api/0/token"
val subscriptionEndpoint = "${this.base}/reader/api/0/subscription/list"
fun subscriptionAdd(url: String) =
"${this.base}/reader/api/0/subscription/quickadd?quickadd=${URLEncoder.encode(url, "utf-8")}"
val unreadCountEndpoint = "${this.base}/reader/api/0/unread-count"
val unreadItemsEndpoint = "${this.base}/reader/api/0/stream/contents/user/-/state/com.google/reading-list"
fun streamContentsEndpoint(id: String) = "${this.base}/reader/api/0/stream/contents/$id"
val tagEndpoint = "${this.base}/reader/api/0/tag/list"
val unreadItemsEndpoint = "${this.base}/reader/api/0/stream/contents/user/-/state/com.google/reading-list"
val editTagEnpoint = "${this.base}/reader/api/0/edit-tag"
}
......@@ -57,3 +57,9 @@ object TagsDeserializer: ResponseDeserializable<List<String>> {
}
}
}
object SubscritpionAddResponseDeserializer: ResponseDeserializable<String> {
override fun deserialize(content: String): String? {
return JSONObject(content).getString("streamId")
}
}
package fr.chenry.android.freshrss.store.database.models
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.*
import com.github.kittinunf.fuel.core.ResponseDeserializable
import io.reactivex.Flowable
import org.joda.time.LocalDateTime
......@@ -19,6 +13,8 @@ data class Account(
val login: String = "default_user",
val serverInstance: String = ""
) {
// Limit to only one user for now
@PrimaryKey
var id = 1
......@@ -31,9 +27,10 @@ data class Account(
}
@Ignore
private var writeTokenBirth = LocalDateTime.now()
val isWriteTokenExpired get() = writeTokenBirth.isBefore(LocalDateTime.now().minusMinutes(30))
val isWriteTokenExpired
get() = writeToken.isBlank() || writeTokenBirth.isBefore(LocalDateTime.now().minusMinutes(30))
companion object : ResponseDeserializable<Account> {
companion object: ResponseDeserializable<Account> {
override fun deserialize(content: String): Account {
val properties = Properties()
properties.load(content.reader())
......@@ -49,6 +46,8 @@ val VoidAccount = Account("", "", serverInstance = "localhost")
@Dao
interface AuthTokensDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(account: Account)
......
......@@ -2,6 +2,7 @@ package fr.chenry.android.freshrss.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.Uri
import android.util.Log
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
......@@ -10,26 +11,24 @@ import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.*
import com.github.kittinunf.fuel.jackson.responseObject
import com.github.kittinunf.result.Result
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.models.Account
import nl.komponents.kovenant.Kovenant.deferred
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.task
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.apache.commons.text.StringEscapeUtils
import org.apache.commons.text.WordUtils
import java.net.URL
import kotlin.reflect.full.companionObjectInstance
val JACKSON_OBJECT_MAPPER: ObjectMapper = ObjectMapper().registerKotlinModule()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(JodaModule())
inline fun <reified T : Any> Request.promiseWithSerializer(
inline fun <reified T: Any> Request.promiseWithSerializer(
loader: ResponseDeserializable<T>? = null
): Promise<T, Exception> {
if (loader == null && T::class.companionObjectInstance !is ResponseDeserializable<*>) {
if(loader == null && T::class.companionObjectInstance !is ResponseDeserializable<*>) {
val exception = IllegalStateException(
"""${T::class.simpleName} must have a companion object implementing
ResponseDeserializable<${T::class.simpleName}> or `loader`
......@@ -40,75 +39,88 @@ inline fun <reified T : Any> Request.promiseWithSerializer(
@Suppress("UNCHECKED_CAST")
val finalLoader = loader ?: T::class.companionObjectInstance as ResponseDeserializable<T>
return asyncRequest { responseObject(finalLoader) }
return asyncRequest {responseObject(finalLoader)}
}
inline fun <reified T : Any> Request.promise() = asyncRequest { responseObject<T>(JACKSON_OBJECT_MAPPER) }
inline fun <reified T: Any> Request.promise() =
asyncRequest {responseObject<T>(JACKSON_OBJECT_MAPPER)}
fun Request.promiseString() = asyncRequest { responseString() }
fun Request.promiseString() = asyncRequest {responseString()}
fun <T : Any> asyncRequest(callback: () -> Triple<Request, Response, Result<T, FuelError>>): Promise<T, Exception> {
fun <T: Any> asyncRequest(callback: () -> ResponseResultOf<T>): Promise<T, Exception> {
val deferred = deferred<T, Exception>()
val onSuccess = { triple: Triple<Request, Response, Result<T, FuelError>> ->
val (_, _, result) = triple
when (result) {
is Result.Success -> deferred.resolve(result.value)
is Result.Failure -> deferred.reject(result.error)
}
val onSuccess = {triple: ResponseResultOf<T> ->
val (request, _, result) = triple
Log.d(Request::class.qualifiedName, request.url.toString())
result.fold(deferred::resolve, deferred::reject)
}
if (Store.debugMode) {
task { callback() } successUi (onSuccess) failUi {
Log.e(Request::class.qualifiedName, "HTTP request failed", it)
deferred.reject(it)
}
} else {
task { callback() } success (onSuccess) fail (deferred::reject)
task {callback()} success (onSuccess) fail {
Log.w(Request::class.qualifiedName, "HTTP request failed", it)
deferred.reject(it)
}
return deferred.promise
}
fun Fuel.postJson(path: String, parameters: List<Pair<String, Any?>>? = null) =
Fuel.post(path, parameters.orEmpty() + ("output" to "json"))
post(path, parameters.orEmpty() + ("output" to "json"))
fun Fuel.getJson(path: String, parameters: List<Pair<String, Any?>>? = null) =
Fuel.get(path, parameters.orEmpty() + ("output" to "json"))
get(path, parameters.orEmpty() + ("output" to "json"))
fun Request.authentify(account: Account) =
this.header("Authorization" to "GoogleLogin auth=${account.SID}")
fun Request.authentify(account: Account) = this.header("Authorization" to "GoogleLogin auth=${account.SID}")
fun Request.authorize(account: Account) = this.header("T" to account.writeToken)
fun Any.v(message: String) = Log.v(this::class.qualifiedName, message)
fun Any.v(message: Throwable) = Log.v(this::class.qualifiedName, "VERBOSE", message)
fun Any.d(message: String) = Log.d(this::class.qualifiedName, message)
fun Any.d(message: Throwable) = Log.d(this::class.qualifiedName, "DEBUG", message)
fun Any.i(message: String) = Log.i(this::class.qualifiedName, message)
fun Any.i(message: Throwable) = Log.i(this::class.qualifiedName, "ERROR", message)
fun Any.w(message: String) = Log.w(this::class.qualifiedName, message)
fun Any.w(message: Throwable) = Log.w(this::class.qualifiedName, "WARNNG", message)
fun Any.e(message: String) = Log.e(this::class.qualifiedName, message)
fun Any.e(message: Throwable) = Log.e(this::class.qualifiedName, "ERROR", message)
fun Any.wtf(message: String) = Log.wtf(this::class.qualifiedName, message)
fun Any.wtf(message: Throwable) = Log.wtf(this::class.qualifiedName, "WTF", message)
fun String.cleanUrlSlashes() = this.replace("//+".toRegex(), "/")
fun Any.unit(): Unit = Unit
fun Any.v(message: String) = Log.v(this::class.qualifiedName, message).unit()
fun Any.v(message: Throwable) = Log.v(this::class.qualifiedName, "VERBOSE", message).unit()
fun Any.d(message: String) = Log.d(this::class.qualifiedName, message).unit()
fun Any.d(message: Throwable) = Log.d(this::class.qualifiedName, "DEBUG", message).unit()
fun Any.i(message: String) = Log.i(this::class.qualifiedName, message).unit()
fun Any.i(message: Throwable) = Log.i(this::class.qualifiedName, "ERROR", message).unit()
fun Any.w(message: String) = Log.w(this::class.qualifiedName, message).unit()
fun Any.w(message: Throwable) = Log.w(this::class.qualifiedName, "WARNNG", message).unit()
fun Any.e(message: String) = Log.e(this::class.qualifiedName, message).unit()
fun Any.e(message: Throwable) = Log.e(this::class.qualifiedName, "ERROR", message).unit()
fun Any.wtf(message: String) = Log.wtf(this::class.qualifiedName, message).unit()
fun Any.wtf(message: Throwable) = Log.wtf(this::class.qualifiedName, "WTF", message).unit()
fun String.addTrailingSlash() = if(this.endsWith("/")) this else "$this/"
fun String.removeNewLines() = this.replace("\\s+".toRegex(), " ")
fun String.escapeHtml4() = StringEscapeUtils.escapeHtml4(this).orEmpty()
fun String.unescapeHtml4() = StringEscapeUtils.unescapeHtml4(this).orEmpty()
fun String.capitalizeFull() = WordUtils.capitalize(this)
fun String?.nullIfBlank() = if (this.isNullOrBlank()) null else this
fun String?.nullIfBlank() = if(this.isNullOrBlank()) null else this
fun String?.extractURLs(): List<Uri> = if(this.isNullOrBlank()) listOf() else {
this
.split("\\s+").mapNotNull {
Try {Uri.parse(URL(it).toString())}.getOrNull()
}
}
fun <V, R> Promise<V, Exception>.bindVoid(fn: () -> Promise<R, Exception>): Promise<R, Exception> = this.bind {fn()}
fun <V, E> Promise<V, E>.getOrDefault(default: V) = try {
this.get()
} catch (e: Exception) {
} catch(e: Exception) {
this.w(e)
default
}
fun <T : Any> T?.whenNotNull(body: (T) -> Unit) = if (this !== null) body(this).let { this } else this