Convert API clients to kotlin (#5567)

* Convert UserClient to kotlin

* Added tests for WikiBaseClient

* Removed superfluous dao tests in review helper and got the proper ReviewDaoTest running in the unit test suite

* Improved tests for ReviewHelper

* Convert the ReviewHelper to kotlin

* Convert the WikiBaseClient to kotlin

* Convert the WikidataClient to kotlin
This commit is contained in:
Paul Hawke 2024-02-19 18:23:11 -06:00 committed by GitHub
parent c43405267a
commit 728712c4e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 496 additions and 526 deletions

View file

@ -1,46 +0,0 @@
package fr.free.nrw.commons.mwapi;
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse;
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResult;
import fr.free.nrw.commons.wikidata.mwapi.UserInfo;
import fr.free.nrw.commons.utils.DateUtil;
import java.util.Collections;
import java.util.Date;
import javax.inject.Inject;
import io.reactivex.Observable;
import io.reactivex.Single;
public class UserClient {
private final UserInterface userInterface;
@Inject
public UserClient(UserInterface userInterface) {
this.userInterface = userInterface;
}
/**
* Checks to see if a user is currently blocked from Commons
*
* @return whether or not the user is blocked from Commons
*/
public Single<Boolean> isUserBlockedFromCommons() {
return userInterface.getUserBlockInfo()
.map(MwQueryResponse::query)
.map(MwQueryResult::userInfo)
.map(UserInfo::blockexpiry)
.map(blockExpiry -> {
if (blockExpiry.isEmpty())
return false;
else if ("infinite".equals(blockExpiry))
return true;
else {
Date endDate = DateUtil.iso8601DateParse(blockExpiry);
Date current = new Date();
return endDate.after(current);
}
}).single(false);
}
}

View file

@ -0,0 +1,36 @@
package fr.free.nrw.commons.mwapi
import fr.free.nrw.commons.utils.DateUtil
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResult
import fr.free.nrw.commons.wikidata.mwapi.UserInfo
import io.reactivex.Single
import java.text.ParseException
import java.util.Date
import javax.inject.Inject
class UserClient @Inject constructor(private val userInterface: UserInterface) {
/**
* Checks to see if a user is currently blocked from Commons
*
* @return whether or not the user is blocked from Commons
*/
fun isUserBlockedFromCommons(): Single<Boolean> =
userInterface.getUserBlockInfo()
.map(::processBlockExpiry)
.single(false)
@Throws(ParseException::class)
private fun processBlockExpiry(response: MwQueryResponse): Boolean {
val blockExpiry = response.query()?.userInfo()?.blockexpiry()
return when {
blockExpiry.isNullOrEmpty() -> false
"infinite" == blockExpiry -> true
else -> {
val endDate = DateUtil.iso8601DateParse(blockExpiry)
val current = Date()
endDate.after(current)
}
}
}
}

View file

@ -1,166 +0,0 @@
package fr.free.nrw.commons.review;
import androidx.annotation.VisibleForTesting;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.media.MediaClient;
import io.reactivex.Completable;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.util.Collections;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.apache.commons.lang3.StringUtils;
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage;
import timber.log.Timber;
@Singleton
public class ReviewHelper {
private static final String[] imageExtensions = new String[]{".jpg", ".jpeg", ".png"};
private final MediaClient mediaClient;
private final ReviewInterface reviewInterface;
@Inject
ReviewDao dao;
@Inject
public ReviewHelper(MediaClient mediaClient, ReviewInterface reviewInterface) {
this.mediaClient = mediaClient;
this.reviewInterface = reviewInterface;
}
/**
* Fetches recent changes from MediaWiki API
* Calls the API to get the latest 50 changes
* When more results are available, the query gets continued beyond this range
*
* @return
*/
private Observable<MwQueryPage> getRecentChanges() {
return reviewInterface.getRecentChanges()
.map(mwQueryResponse -> mwQueryResponse.query().pages())
.map(recentChanges -> {
Collections.shuffle(recentChanges);
return recentChanges;
})
.flatMapIterable(changes -> changes)
.filter(recentChange -> isChangeReviewable(recentChange));
}
/**
* Gets a random file change for review.
* - Picks a random file from those changes
* - Checks if the file is nominated for deletion
* - Retries upto 5 times for getting a file which is not nominated for deletion
*
* @return Random file change
*/
public Single<Media> getRandomMedia() {
return getRecentChanges()
.flatMapSingle(change -> getRandomMediaFromRecentChange(change))
.filter(media -> !StringUtils.isBlank(media.getFilename())
&& !getReviewStatus(media.getPageId()) // Check if the image has already been shown to the user
)
.firstOrError();
}
/**
* Returns a proper Media object if the file is not already nominated for deletion
* Else it returns an empty Media object
*
* @param recentChange
* @return
*/
private Single<Media> getRandomMediaFromRecentChange(MwQueryPage recentChange) {
return Single.just(recentChange)
.flatMap(change -> mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + change.title()))
.flatMap(isDeleted -> {
if (isDeleted) {
return Single.error(new Exception(recentChange.title() + " is deleted"));
}
return mediaClient.getMedia(recentChange.title());
});
}
/**
* Checks if the image exists in the reviewed images entity
*
* @param image
* @return
*/
@VisibleForTesting
Boolean getReviewStatus(String image){
if(dao == null){
return false;
}
return Observable.fromCallable(()-> dao.isReviewedAlready(image))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()).blockingSingle();
}
/**
* Gets the first revision of the file from filename
*
* @param filename
* @return
*/
public Observable<MwQueryPage.Revision> getFirstRevisionOfFile(String filename) {
return reviewInterface.getFirstRevisionOfFile(filename)
.map(response -> response.query().firstPage().revisions().get(0));
}
/**
* Checks Whether Given File is used in any Wiki page or not
* by calling api for given file
*
* @param filename
* @return
*/
Observable<Boolean> checkFileUsage(final String filename) {
return reviewInterface.getGlobalUsageInfo(filename)
.map(mwQueryResponse -> mwQueryResponse.query().firstPage()
.checkWhetherFileIsUsedInWikis());
}
/**
* Checks if the change is reviewable or not.
* - checks the type and revisionId of the change
* - checks supported image extensions
*
* @param recentChange
* @return
*/
private boolean isChangeReviewable(MwQueryPage recentChange) {
for (String extension : imageExtensions) {
if (recentChange.title().endsWith(extension)) {
return true;
}
}
return false;
}
/**
* Adds reviewed/skipped images to the database
*
* @param imageId
*/
public void addViewedImagesToDB(String imageId) {
Completable.fromAction(() -> dao.insert(new ReviewEntity(imageId)))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
// Inserted successfully
Timber.i("Image inserted successfully.");
},
throwable -> {
Timber.e("Image not inserted into the reviewed images database");
}
);
}
}

View file

@ -0,0 +1,138 @@
package fr.free.nrw.commons.review
import androidx.annotation.VisibleForTesting
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage.Revision
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.apache.commons.lang3.StringUtils
import timber.log.Timber
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ReviewHelper @Inject constructor(
private val mediaClient: MediaClient,
private val reviewInterface: ReviewInterface
) {
@JvmField @Inject var dao: ReviewDao? = null
/**
* Fetches recent changes from MediaWiki API
* Calls the API to get the latest 50 changes
* When more results are available, the query gets continued beyond this range
*
* @return
*/
private fun getRecentChanges() = reviewInterface.getRecentChanges()
.map { it.query()?.pages() }
.map(MutableList<MwQueryPage>::shuffled)
.flatMapIterable { changes: List<MwQueryPage>? -> changes }
.filter { isChangeReviewable(it) }
/**
* Gets a random file change for review. Checks if the image has already been shown to the user
* - Picks a random file from those changes
* - Checks if the file is nominated for deletion
* - Retries upto 5 times for getting a file which is not nominated for deletion
*
* @return Random file change
*/
fun getRandomMedia(): Single<Media> = getRecentChanges()
.flatMapSingle(::getRandomMediaFromRecentChange)
.filter { !it.filename.isNullOrBlank() && !getReviewStatus(it.pageId) }
.firstOrError()
/**
* Returns a proper Media object if the file is not already nominated for deletion
* Else it returns an empty Media object
*
* @param recentChange
* @return
*/
private fun getRandomMediaFromRecentChange(recentChange: MwQueryPage) =
Single.just(recentChange)
.flatMap { mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/${it.title()}") }
.flatMap {
if (it) {
Single.error(Exception("${recentChange.title()} is deleted"))
} else {
mediaClient.getMedia(recentChange.title())
}
}
/**
* Checks if the image exists in the reviewed images entity
*
* @param image
* @return
*/
fun getReviewStatus(image: String?): Boolean =
dao?.isReviewedAlready(image) ?: false
/**
* Gets the first revision of the file from filename
*
* @param filename
* @return
*/
fun getFirstRevisionOfFile(filename: String?): Observable<Revision> =
reviewInterface.getFirstRevisionOfFile(filename)
.map { it.query()?.firstPage()?.revisions()?.get(0) }
/**
* Checks Whether Given File is used in any Wiki page or not
* by calling api for given file
*
* @param filename
* @return
*/
fun checkFileUsage(filename: String?): Observable<Boolean> =
reviewInterface.getGlobalUsageInfo(filename)
.map { it.query()?.firstPage()?.checkWhetherFileIsUsedInWikis() }
/**
* Checks if the change is reviewable or not.
* - checks the type and revisionId of the change
* - checks supported image extensions
*
* @param recentChange
* @return
*/
private fun isChangeReviewable(recentChange: MwQueryPage): Boolean {
for (extension in imageExtensions) {
if (recentChange.title().endsWith(extension)) {
return true
}
}
return false
}
/**
* Adds reviewed/skipped images to the database
*
* @param imageId
*/
fun addViewedImagesToDB(imageId: String?) {
Completable.fromAction { dao!!.insert(ReviewEntity(imageId)) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
// Inserted successfully
Timber.i("Image inserted successfully.")
}
) { throwable: Throwable? -> Timber.e("Image not inserted into the reviewed images database") }
}
companion object {
private val imageExtensions = arrayOf(".jpg", ".jpeg", ".png")
}
}

View file

@ -1,76 +0,0 @@
package fr.free.nrw.commons.wikidata;
import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF;
import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX;
import fr.free.nrw.commons.upload.UploadResult;
import fr.free.nrw.commons.upload.WikiBaseInterface;
import io.reactivex.Observable;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse;
import timber.log.Timber;
/**
* Wikibase Client for calling WikiBase APIs
*/
@Singleton
public class WikiBaseClient {
private final WikiBaseInterface wikiBaseInterface;
private final CsrfTokenClient csrfTokenClient;
@Inject
public WikiBaseClient(WikiBaseInterface wikiBaseInterface,
@Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient) {
this.wikiBaseInterface = wikiBaseInterface;
this.csrfTokenClient = csrfTokenClient;
}
public Observable<Boolean> postEditEntity(String fileEntityId, String data) {
return csrfToken()
.switchMap(editToken -> wikiBaseInterface.postEditEntity(fileEntityId, editToken, data)
.map(response -> (response.getSuccessVal() == 1)));
}
/**
* Makes the server call for posting new depicts
*
* @param filename name of the file
* @param data data of the depicts to be uploaded
* @return Observable<Boolean>
*/
public Observable<Boolean> postEditEntityByFilename(final String filename, final String data) {
return csrfToken()
.switchMap(editToken -> wikiBaseInterface.postEditEntityByFilename(filename,
editToken, data)
.map(response -> (response.getSuccessVal() == 1)));
}
public Observable<Long> getFileEntityId(UploadResult uploadResult) {
return wikiBaseInterface.getFileEntityId(uploadResult.createCanonicalFileName())
.map(response -> (long) (response.query().pages().get(0).pageId()));
}
public Observable<MwPostResponse> addLabelstoWikidata(long fileEntityId,
String languageCode, String captionValue) {
return csrfToken()
.switchMap(editToken -> wikiBaseInterface
.addLabelstoWikidata(PAGE_ID_PREFIX + fileEntityId, editToken, languageCode,
captionValue));
}
private Observable<String> csrfToken() {
return Observable.fromCallable(() -> {
try {
return csrfTokenClient.getTokenBlocking();
} catch (Throwable throwable) {
Timber.e(throwable);
return "";
}
});
}
}

View file

@ -0,0 +1,69 @@
package fr.free.nrw.commons.wikidata
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.di.NetworkingModule
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import fr.free.nrw.commons.upload.UploadResult
import fr.free.nrw.commons.upload.WikiBaseInterface
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Observable
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* Wikibase Client for calling WikiBase APIs
*/
@Singleton
class WikiBaseClient @Inject constructor(
private val wikiBaseInterface: WikiBaseInterface,
@param:Named(NetworkingModule.NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient
) {
fun postEditEntity(fileEntityId: String?, data: String?): Observable<Boolean> {
return csrfToken().switchMap { editToken ->
wikiBaseInterface.postEditEntity(fileEntityId!!, editToken, data!!)
.map { response: MwPostResponse -> response.successVal == 1 }
}
}
/**
* Makes the server call for posting new depicts
*
* @param filename name of the file
* @param data data of the depicts to be uploaded
* @return Observable<Boolean>
</Boolean> */
fun postEditEntityByFilename(filename: String?, data: String?): Observable<Boolean> {
return csrfToken().switchMap { editToken ->
wikiBaseInterface.postEditEntityByFilename(filename!!, editToken, data!!)
.map { response: MwPostResponse -> response.successVal == 1 }
}
}
fun getFileEntityId(uploadResult: UploadResult): Observable<Long> {
return wikiBaseInterface.getFileEntityId(uploadResult.createCanonicalFileName())
.map { response: MwQueryResponse -> response.query()!!.pages()!![0].pageId().toLong() }
}
fun addLabelstoWikidata(fileEntityId: Long, languageCode: String?, captionValue: String?): Observable<MwPostResponse> {
return csrfToken().switchMap { editToken ->
wikiBaseInterface.addLabelstoWikidata(
PAGE_ID_PREFIX + fileEntityId,
editToken,
languageCode,
captionValue
)
}
}
private fun csrfToken(): Observable<String> = Observable.fromCallable {
try {
csrfTokenClient.getTokenBlocking()
} catch (throwable: Throwable) {
Timber.e(throwable)
""
}
}
}

View file

@ -1,47 +0,0 @@
package fr.free.nrw.commons.wikidata;
import com.google.gson.Gson;
import org.jetbrains.annotations.NotNull;
import javax.inject.Inject;
import javax.inject.Singleton;
import fr.free.nrw.commons.wikidata.model.AddEditTagResponse;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
import fr.free.nrw.commons.wikidata.model.Statement_partial;
@Singleton
public class WikidataClient {
private final WikidataInterface wikidataInterface;
private final Gson gson;
@Inject
public WikidataClient(WikidataInterface wikidataInterface, final Gson gson) {
this.wikidataInterface = wikidataInterface;
this.gson = gson;
}
/**
* Create wikidata claim to add P18 value
*
* @return revisionID of the edit
*/
Observable<Long> setClaim(Statement_partial claim, String tags) {
return getCsrfToken()
.flatMap(
csrfToken -> wikidataInterface.postSetClaim(gson.toJson(claim), tags, csrfToken))
.map(mwPostResponse -> mwPostResponse.getPageinfo().getLastrevid());
}
/**
* Get csrf token for wikidata edit
*/
@NotNull
private Observable<String> getCsrfToken() {
return wikidataInterface.getCsrfToken()
.map(mwQueryResponse -> mwQueryResponse.query().csrfToken());
}
}

View file

@ -0,0 +1,32 @@
package fr.free.nrw.commons.wikidata
import com.google.gson.Gson
import fr.free.nrw.commons.wikidata.model.Statement_partial
import fr.free.nrw.commons.wikidata.model.WbCreateClaimResponse
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Observable
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WikidataClient @Inject constructor(
private val wikidataInterface: WikidataInterface,
private val gson: Gson
) {
/**
* Create wikidata claim to add P18 value
*
* @return revisionID of the edit
*/
fun setClaim(claim: Statement_partial?, tags: String?): Observable<Long> {
return csrfToken().flatMap { csrfToken: String? ->
wikidataInterface.postSetClaim(gson.toJson(claim), tags!!, csrfToken!!)
}.map { mwPostResponse: WbCreateClaimResponse -> mwPostResponse.pageinfo.lastrevid }
}
/**
* Get csrf token for wikidata edit
*/
private fun csrfToken(): Observable<String?> =
wikidataInterface.getCsrfToken().map { it.query()?.csrfToken() }
}