Revert "Merge branch 'backend-overhaul' into master" (#3125)

* Revert "Merge branch 'backend-overhaul' into master"

This reverts commit 0090f24257, reversing
changes made to 9bccbfe443.

* fixed test handleSubmitTest
This commit is contained in:
Ashish Kumar 2019-08-12 14:32:25 +05:30 committed by Vivek Maskara
parent cbdfb05530
commit 5865d59d22
77 changed files with 3471 additions and 1816 deletions

View file

@ -1,11 +1,12 @@
package fr.free.nrw.commons.mwapi;
import android.content.Context;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.Gson;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
@ -16,19 +17,34 @@ import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.CoreProtocolPNames;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.wikipedia.util.DateUtil;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.AccountUtil;
import fr.free.nrw.commons.category.CategoryImageUtils;
import fr.free.nrw.commons.category.QueryContinue;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.notification.Notification;
import fr.free.nrw.commons.notification.NotificationUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.Observable;
import io.reactivex.Single;
import timber.log.Timber;
@ -38,8 +54,19 @@ import timber.log.Timber;
public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
private AbstractHttpClient httpClient;
private CustomMwApi api;
private CustomMwApi wikidataApi;
private Context context;
private JsonKvStore defaultKvStore;
private Gson gson;
public ApacheHttpClientMediaWikiApi(String apiURL) {
private final String ERROR_CODE_BAD_TOKEN = "badtoken";
public ApacheHttpClientMediaWikiApi(Context context,
String apiURL,
String wikidatApiURL,
JsonKvStore defaultKvStore,
Gson gson) {
this.context = context;
BasicHttpParams params = new BasicHttpParams();
SchemeRegistry schemeRegistry = new SchemeRegistry();
schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
@ -52,6 +79,217 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
httpClient.addRequestInterceptor(NetworkInterceptors.getHttpRequestInterceptor());
}
api = new CustomMwApi(apiURL, httpClient);
wikidataApi = new CustomMwApi(wikidatApiURL, httpClient);
this.defaultKvStore = defaultKvStore;
this.gson = gson;
}
/**
* @param username String
* @param password String
* @return String as returned by this.getErrorCodeToReturn()
* @throws IOException On api request IO issue
*/
public String login(String username, String password) throws IOException {
String loginToken = getLoginToken();
Timber.d("Login token is %s", loginToken);
return getErrorCodeToReturn(api.action("clientlogin")
.param("rememberMe", "1")
.param("username", username)
.param("password", password)
.param("logintoken", loginToken)
.param("loginreturnurl", "https://commons.wikimedia.org")
.post());
}
/**
* @param username String
* @param password String
* @param twoFactorCode String
* @return String as returned by this.getErrorCodeToReturn()
* @throws IOException On api request IO issue
*/
public String login(String username, String password, String twoFactorCode) throws IOException {
String loginToken = getLoginToken();
Timber.d("Login token is %s", loginToken);
return getErrorCodeToReturn(api.action("clientlogin")
.param("rememberMe", "true")
.param("username", username)
.param("password", password)
.param("logintoken", loginToken)
.param("logincontinue", "true")
.param("OATHToken", twoFactorCode)
.post());
}
private String getLoginToken() throws IOException {
return api.action("query")
.param("action", "query")
.param("meta", "tokens")
.param("type", "login")
.post()
.getString("/api/query/tokens/@logintoken");
}
/**
* @param loginCustomApiResult CustomApiResult Any clientlogin api result
* @return String On success: "PASS"
* continue: "2FA" (More information required for 2FA)
* failure: A failure message code (defined by mediawiki)
* misc: genericerror-UI, genericerror-REDIRECT, genericerror-RESTART
*/
private String getErrorCodeToReturn(CustomApiResult loginCustomApiResult) {
String status = loginCustomApiResult.getString("/api/clientlogin/@status");
if (status.equals("PASS")) {
api.isLoggedIn = true;
setAuthCookieOnLogin(true);
return status;
} else if (status.equals("FAIL")) {
setAuthCookieOnLogin(false);
return loginCustomApiResult.getString("/api/clientlogin/@messagecode");
} else if (
status.equals("UI")
&& loginCustomApiResult.getString("/api/clientlogin/requests/_v/@id").equals("TOTPAuthenticationRequest")
&& loginCustomApiResult.getString("/api/clientlogin/requests/_v/@provider").equals("Two-factor authentication (OATH).")
) {
setAuthCookieOnLogin(false);
return "2FA";
}
// UI, REDIRECT, RESTART
return "genericerror-" + status;
}
private void setAuthCookieOnLogin(boolean isLoggedIn) {
if (isLoggedIn) {
defaultKvStore.putBoolean("isUserLoggedIn", true);
defaultKvStore.putString("getAuthCookie", api.getAuthCookie());
} else {
defaultKvStore.putBoolean("isUserLoggedIn", false);
defaultKvStore.remove("getAuthCookie");
}
}
@Override
public String getAuthCookie() {
return api.getAuthCookie();
}
@Override
public void setAuthCookie(String authCookie) {
api.setAuthCookie(authCookie);
}
@Override
public boolean validateLogin() throws IOException {
boolean validateLoginResp = api.validateLogin();
Timber.d("Validate login response is %s", validateLoginResp);
return validateLoginResp;
}
@Override
public String getEditToken() throws IOException {
String editToken = api.action("query")
.param("meta", "tokens")
.post()
.getString("/api/query/tokens/@csrftoken");
Timber.d("MediaWiki edit token is %s", editToken);
return editToken;
}
@Override
public String getCentralAuthToken() throws IOException {
CustomApiResult result = api.action("centralauthtoken").get();
String centralAuthToken = result.getString("/api/centralauthtoken/@centralauthtoken");
Timber.d("MediaWiki Central auth token is %s", centralAuthToken);
if ((centralAuthToken == null || centralAuthToken.isEmpty())
&& "notLoggedIn".equals(result.getString("api/error/@code"))) {
Timber.d("Central auth token isn't valid. Trying to fetch a fresh token");
api.removeAllCookies();
String loginResultCode = login(AccountUtil.getUserName(context), AccountUtil.getPassword(context));
if (loginResultCode.equals("PASS")) {
return getCentralAuthToken();
} else if (loginResultCode.equals("2FA")) {
Timber.e("Cannot refresh session for 2FA enabled user. Login required");
} else {
Timber.e("Error occurred in refreshing session. Error code is %s", loginResultCode);
}
} else {
Timber.e("Error occurred while fetching auth token. Error code is %s and message is %s",
result.getString("api/error/@code"),
result.getString("api/error/@info"));
}
return centralAuthToken;
}
@Override
public boolean fileExistsWithName(String fileName) throws IOException {
return api.action("query")
.param("prop", "imageinfo")
.param("titles", "File:" + fileName)
.get()
.getNodes("/api/query/pages/page/imageinfo").size() > 0;
}
@Override
public Single<Boolean> pageExists(String pageName) {
return Single.fromCallable(() -> Double.parseDouble(api.action("query")
.param("titles", pageName)
.get()
.getString("/api/query/pages/page/@_idx")) != -1);
}
@Override
public boolean thank(String editToken, long revision) throws IOException {
CustomApiResult res = api.action("thank")
.param("rev", revision)
.param("token", editToken)
.param("source", CommonsApplication.getInstance().getUserAgent())
.post();
String r = res.getString("/api/result/@success");
// Does this correctly check the success/failure?
// The docs https://www.mediawiki.org/wiki/Extension:Thanks seems unclear about that.
return r.equals("success");
}
@Override
@Nullable
public String edit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
return api.action("edit")
.param("title", filename)
.param("token", getEditToken())
.param("text", processedPageContent)
.param("summary", summary)
.post()
.getString("/api/edit/@result");
}
@Override
@Nullable
public String appendEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
return api.action("edit")
.param("title", filename)
.param("token", getEditToken())
.param("appendtext", processedPageContent)
.param("summary", summary)
.post()
.getString("/api/edit/@result");
}
@Override
@Nullable
public String prependEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException {
return api.action("edit")
.param("title", filename)
.param("token", getEditToken())
.param("prependtext", processedPageContent)
.param("summary", summary)
.post()
.getString("/api/edit/@result");
}
@Override
@ -83,6 +321,188 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
});
}
@Override
@NonNull
public Observable<String> searchCategories(String filterValue, int searchCatsLimit) {
List<String> categories = new ArrayList<>();
return Single.fromCallable(() -> {
List<CustomApiResult> categoryNodes = null;
try {
categoryNodes = api.action("query")
.param("format", "xml")
.param("list", "search")
.param("srwhat", "text")
.param("srnamespace", "14")
.param("srlimit", searchCatsLimit)
.param("srsearch", filterValue)
.get()
.getNodes("/api/query/search/p/@title");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchCategories");
}
if (categoryNodes == null) {
return new ArrayList<String>();
}
for (CustomApiResult categoryNode : categoryNodes) {
String cat = categoryNode.getDocument().getTextContent();
String catString = cat.replace("Category:", "");
if (!categories.contains(catString)) {
categories.add(catString);
}
}
return categories;
}).flatMapObservable(Observable::fromIterable);
}
@Override
@NonNull
public Observable<String> allCategories(String filterValue, int searchCatsLimit) {
return Single.fromCallable(() -> {
ArrayList<CustomApiResult> categoryNodes = null;
try {
categoryNodes = api.action("query")
.param("list", "allcategories")
.param("acprefix", filterValue)
.param("aclimit", searchCatsLimit)
.get()
.getNodes("/api/query/allcategories/c");
} catch (IOException e) {
Timber.e(e, "Failed to obtain allCategories");
}
if (categoryNodes == null) {
return new ArrayList<String>();
}
List<String> categories = new ArrayList<>();
for (CustomApiResult categoryNode : categoryNodes) {
categories.add(categoryNode.getDocument().getTextContent());
}
return categories;
}).flatMapObservable(Observable::fromIterable);
}
@Override
public String getWikidataCsrfToken() throws IOException {
String wikidataCsrfToken = wikidataApi.action("query")
.param("action", "query")
.param("centralauthtoken", getCentralAuthToken())
.param("meta", "tokens")
.post()
.getString("/api/query/tokens/@csrftoken");
Timber.d("Wikidata csrf token is %s", wikidataCsrfToken);
return wikidataCsrfToken;
}
/**
* Creates a new claim using the wikidata API
* https://www.mediawiki.org/wiki/Wikibase/API
* @param entityId the wikidata entity to be edited
* @param property the property to be edited, for eg P18 for images
* @param snaktype the type of value stored for that property
* @param value the actual value to be stored for the property, for eg filename in case of P18
* @return returns revisionId if the claim is successfully created else returns null
* @throws IOException
*/
@Nullable
@Override
public String wikidataCreateClaim(String entityId, String property, String snaktype, String value) throws IOException {
Timber.d("Filename is %s", value);
CustomApiResult result = wikidataApi.action("wbcreateclaim")
.param("entity", entityId)
.param("centralauthtoken", getCentralAuthToken())
.param("token", getWikidataCsrfToken())
.param("snaktype", snaktype)
.param("property", property)
.param("value", value)
.post();
if (result == null || result.getNode("api") == null) {
return null;
}
Node node = result.getNode("api").getDocument();
Element element = (Element) node;
if (element != null && element.getAttribute("success").equals("1")) {
return result.getString("api/pageinfo/@lastrevid");
} else {
Timber.e(result.getString("api/error/@code") + " " + result.getString("api/error/@info"));
}
return null;
}
/**
* Adds the wikimedia-commons-app tag to the edits made on wikidata
* @param revisionId
* @return
* @throws IOException
*/
@Nullable
@Override
public boolean addWikidataEditTag(String revisionId) throws IOException {
CustomApiResult result = wikidataApi.action("tag")
.param("revid", revisionId)
.param("centralauthtoken", getCentralAuthToken())
.param("token", getWikidataCsrfToken())
.param("add", "wikimedia-commons-app")
.param("reason", "Add tag for edits made using Android Commons app")
.post();
if (result == null || result.getNode("api") == null) {
return false;
}
if ("success".equals(result.getString("api/tag/result/@status"))) {
return true;
} else {
Timber.e("Error occurred in creating claim. Error code is: %s and message is %s",
result.getString("api/error/@code"),
result.getString("api/error/@info"));
}
return false;
}
@Override
@NonNull
public Observable<String> searchTitles(String title, int searchCatsLimit) {
return Single.fromCallable((Callable<List<String>>) () -> {
ArrayList<CustomApiResult> categoryNodes;
try {
categoryNodes = api.action("query")
.param("format", "xml")
.param("list", "search")
.param("srwhat", "text")
.param("srnamespace", "14")
.param("srlimit", searchCatsLimit)
.param("srsearch", title)
.get()
.getNodes("/api/query/search/p/@title");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchTitles");
return Collections.emptyList();
}
if (categoryNodes == null) {
return Collections.emptyList();
}
List<String> titleCategories = new ArrayList<>();
for (CustomApiResult categoryNode : categoryNodes) {
String cat = categoryNode.getDocument().getTextContent();
String catString = cat.replace("Category:", "");
titleCategories.add(catString);
}
return titleCategories;
}).flatMapObservable(Observable::fromIterable);
}
@Override
@NonNull
public LogEventResult logEvents(String user, String lastModified, String queryContinue, int limit) throws IOException {
@ -131,9 +551,282 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi {
.getString("/api/query/pages/page/revisions/rev");
}
@Override
@NonNull
public List<Notification> getNotifications(boolean archived) {
CustomApiResult notificationNode = null;
String notfilter;
try {
if (archived) {
notfilter = "read";
}else {
notfilter = "!read";
}
String language=Locale.getDefault().getLanguage();
if(StringUtils.isBlank(language)){
//if no language is set we use the default user language defined on wikipedia
language="user";
}
notificationNode = api.action("query")
.param("notprop", "list")
.param("format", "xml")
.param("meta", "notifications")
.param("notformat", "model")
.param("notwikis", "wikidatawiki|commonswiki|enwiki")
.param("notfilter", notfilter)
.param("uselang", language)
.get()
.getNode("/api/query/notifications/list");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchCategories");
}
if (notificationNode == null
|| notificationNode.getDocument() == null
|| notificationNode.getDocument().getChildNodes() == null
|| notificationNode.getDocument().getChildNodes().getLength() == 0) {
return new ArrayList<>();
}
NodeList childNodes = notificationNode.getDocument().getChildNodes();
return NotificationUtils.getNotificationsFromList(context, childNodes);
}
@Override
public boolean markNotificationAsRead(Notification notification) throws IOException {
Timber.d("Trying to mark notification as read: %s", notification.toString());
String result = api.action("echomarkread")
.param("token", getEditToken())
.param("list", notification.notificationId)
.post()
.getString("/api/query/echomarkread/@result");
if (StringUtils.isBlank(result)) {
return false;
}
return result.equals("success");
}
/**
* The method takes categoryName as input and returns a List of Subcategories
* It uses the generator query API to get the subcategories in a category, 500 at a time.
* Uses the query continue values for fetching paginated responses
* @param categoryName Category name as defined on commons
* @return
*/
@Override
@NonNull
public List<String> getSubCategoryList(String categoryName) {
CustomApiResult apiResult = null;
try {
CustomMwApi.RequestBuilder requestBuilder = api.action("query")
.param("generator", "categorymembers")
.param("format", "xml")
.param("gcmtype","subcat")
.param("gcmtitle", categoryName)
.param("prop", "info")
.param("gcmlimit", "500")
.param("iiprop", "url|extmetadata");
apiResult = requestBuilder.get();
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchCategories");
}
if (apiResult == null) {
return new ArrayList<>();
}
CustomApiResult categoryImagesNode = apiResult.getNode("/api/query/pages");
if (categoryImagesNode == null
|| categoryImagesNode.getDocument() == null
|| categoryImagesNode.getDocument().getChildNodes() == null
|| categoryImagesNode.getDocument().getChildNodes().getLength() == 0) {
return new ArrayList<>();
}
NodeList childNodes = categoryImagesNode.getDocument().getChildNodes();
return CategoryImageUtils.getSubCategoryList(childNodes);
}
/**
* The method takes categoryName as input and returns a List of parent categories
* It uses the generator query API to get the parent categories of a category, 500 at a time.
* @param categoryName Category name as defined on commons
* @return
*/
@Override
@NonNull
public List<String> getParentCategoryList(String categoryName) {
CustomApiResult apiResult = null;
try {
CustomMwApi.RequestBuilder requestBuilder = api.action("query")
.param("generator", "categories")
.param("format", "xml")
.param("titles", categoryName)
.param("prop", "info")
.param("cllimit", "500")
.param("iiprop", "url|extmetadata");
apiResult = requestBuilder.get();
} catch (IOException e) {
Timber.e(e, "Failed to obtain parent Categories");
}
if (apiResult == null) {
return new ArrayList<>();
}
CustomApiResult categoryImagesNode = apiResult.getNode("/api/query/pages");
if (categoryImagesNode == null
|| categoryImagesNode.getDocument() == null
|| categoryImagesNode.getDocument().getChildNodes() == null
|| categoryImagesNode.getDocument().getChildNodes().getLength() == 0) {
return new ArrayList<>();
}
NodeList childNodes = categoryImagesNode.getDocument().getChildNodes();
return CategoryImageUtils.getSubCategoryList(childNodes);
}
/**
* This method takes search keyword as input and returns a list of categories objects filtered using search query
* It uses the generator query API to get the categories searched using a query, 25 at a time.
* @param query keyword to search categories on commons
* @return
*/
@Override
@NonNull
public List<String> searchCategory(String query, int offset) {
List<CustomApiResult> categoryNodes = null;
try {
categoryNodes = api.action("query")
.param("format", "xml")
.param("list", "search")
.param("srwhat", "text")
.param("srnamespace", "14")
.param("srlimit", "25")
.param("sroffset",offset)
.param("srsearch", query)
.get()
.getNodes("/api/query/search/p/@title");
} catch (IOException e) {
Timber.e(e, "Failed to obtain searchCategories");
}
if (categoryNodes == null) {
return new ArrayList<>();
}
List<String> categories = new ArrayList<>();
for (CustomApiResult categoryNode : categoryNodes) {
String catName = categoryNode.getDocument().getTextContent();
categories.add(catName);
}
return categories;
}
/**
* For APIs that return paginated responses, MediaWiki APIs uses the QueryContinue to facilitate fetching of subsequent pages
* https://www.mediawiki.org/wiki/API:Raw_query_continue
* After fetching images a page of image for a particular category, shared defaultKvStore are updated with the latest QueryContinue Values
* @param keyword
* @param queryContinue
*/
private void setQueryContinueValues(String keyword, QueryContinue queryContinue) {
defaultKvStore.putString(keyword, gson.toJson(queryContinue));
}
/**
* Before making a paginated API call, this method is called to get the latest query continue values to be used
* @param keyword
* @return
*/
@Nullable
private QueryContinue getQueryContinueValues(String keyword) {
String queryContinueString = defaultKvStore.getString(keyword, null);
return gson.fromJson(queryContinueString, QueryContinue.class);
}
@Override
public boolean existingFile(String fileSha1) throws IOException {
return api.action("query")
.param("format", "xml")
.param("list", "allimages")
.param("aisha1", fileSha1)
.get()
.getNodes("/api/query/allimages/img").size() > 0;
}
@Override
@NonNull
public Single<UploadStash> uploadFile(
String filename,
@NonNull InputStream file,
long dataLength,
Uri fileUri,
Uri contentProviderUri,
ProgressListener progressListener) {
return Single.fromCallable(() -> {
CustomApiResult result = api.uploadToStash(filename, file, dataLength, getEditToken(), progressListener::onProgress);
Timber.wtf("Result: " + result.toString());
String resultStatus = result.getString("/api/upload/@result");
if (!resultStatus.equals("Success")) {
String errorCode = result.getString("/api/error/@code");
Timber.e(errorCode);
if (errorCode.equals(ERROR_CODE_BAD_TOKEN)) {
ViewUtil.showLongToast(context, R.string.bad_token_error_proposed_solution);
}
return new UploadStash(errorCode, resultStatus, filename, "");
} else {
String filekey = result.getString("/api/upload/@filekey");
return new UploadStash("", resultStatus, filename, filekey);
}
});
}
@Override
@NonNull
public Single<UploadResult> uploadFileFinalize(
String filename,
String filekey,
String pageContents,
String editSummary) throws IOException {
return Single.fromCallable(() -> {
CustomApiResult result = api.uploadFromStash(
filename, filekey, pageContents, editSummary,
getEditToken());
Timber.d("Result: %s", result.toString());
String resultStatus = result.getString("/api/upload/@result");
if (!resultStatus.equals("Success")) {
String errorCode = result.getString("/api/error/@code");
Timber.e(errorCode);
if (errorCode.equals(ERROR_CODE_BAD_TOKEN)) {
ViewUtil.showLongToast(context, R.string.bad_token_error_proposed_solution);
}
return new UploadResult(resultStatus, errorCode);
} else {
Date dateUploaded = parseMWDate(result.getString("/api/upload/imageinfo/@timestamp"));
String canonicalFilename = "File:" + result.getString("/api/upload/@filename")
.replace("_", " ")
.trim(); // Title vs Filename
String imageUrl = result.getString("/api/upload/imageinfo/@url");
return new UploadResult(resultStatus, dateUploaded, canonicalFilename, imageUrl);
}
});
}
/**
* Checks to see if a user is currently blocked from Commons
*
* @return whether or not the user is blocked from Commons
*/
@Override

View file

@ -1,22 +1,94 @@
package fr.free.nrw.commons.mwapi;
import android.net.Uri;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.IOException;
import fr.free.nrw.commons.notification.Notification;
import io.reactivex.Observable;
import io.reactivex.Single;
public interface MediaWikiApi {
String getAuthCookie();
void setAuthCookie(String authCookie);
String login(String username, String password) throws IOException;
String login(String username, String password, String twoFactorCode) throws IOException;
boolean validateLogin() throws IOException;
String getEditToken() throws IOException;
String getWikidataCsrfToken() throws IOException;
String getCentralAuthToken() throws IOException;
boolean fileExistsWithName(String fileName) throws IOException;
Single<Boolean> pageExists(String pageName);
List<String> getSubCategoryList(String categoryName);
List<String> getParentCategoryList(String categoryName);
@NonNull
List<String> searchCategory(String title, int offset);
@NonNull
Single<UploadStash> uploadFile(String filename, InputStream file,
long dataLength, Uri fileUri, Uri contentProviderUri,
final ProgressListener progressListener);
@NonNull
Single<UploadResult> uploadFileFinalize(String filename, String filekey,
String pageContents, String editSummary) throws IOException;
@Nullable
String edit(String editToken, String processedPageContent, String filename, String summary) throws IOException;
@Nullable
String prependEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException;
@Nullable
String appendEdit(String editToken, String processedPageContent, String filename, String summary) throws IOException;
@Nullable
String wikidataCreateClaim(String entityId, String property, String snaktype, String value) throws IOException;
@Nullable
boolean addWikidataEditTag(String revisionId) throws IOException;
Single<String> parseWikicode(String source);
@NonNull
Single<MediaResult> fetchMediaByFilename(String filename);
@NonNull
Observable<String> searchCategories(String filterValue, int searchCatsLimit);
@NonNull
Observable<String> allCategories(String filter, int searchCatsLimit);
@NonNull
List<Notification> getNotifications(boolean archived) throws IOException;
@NonNull
boolean markNotificationAsRead(Notification notification) throws IOException;
@NonNull
Observable<String> searchTitles(String title, int searchCatsLimit);
@Nullable
String revisionsByFilename(String filename) throws IOException;
boolean existingFile(String fileSha1) throws IOException;
@NonNull
LogEventResult logEvents(String user, String lastModified, String queryContinue, int limit) throws IOException;
@ -26,6 +98,8 @@ public interface MediaWikiApi {
// Single<CampaignResponseDTO> getCampaigns();
boolean thank(String editToken, long revision) throws IOException;
interface ProgressListener {
void onProgress(long transferred, long total);
}

View file

@ -46,11 +46,15 @@ import timber.log.Timber;
public class OkHttpJsonApiClient {
private static final String THUMB_SIZE = "640";
public static final Type mapType = new TypeToken<Map<String, String>>() {
}.getType();
private final OkHttpClient okHttpClient;
private final HttpUrl wikiMediaToolforgeUrl;
private final String sparqlQueryUrl;
private final String campaignsUrl;
private final String commonsBaseUrl;
private final JsonKvStore defaultKvStore;
private Gson gson;
@ -60,12 +64,14 @@ public class OkHttpJsonApiClient {
String sparqlQueryUrl,
String campaignsUrl,
String commonsBaseUrl,
JsonKvStore defaultKvStore,
Gson gson) {
this.okHttpClient = okHttpClient;
this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl;
this.sparqlQueryUrl = sparqlQueryUrl;
this.campaignsUrl = campaignsUrl;
this.commonsBaseUrl = commonsBaseUrl;
this.defaultKvStore = defaultKvStore;
this.gson = gson;
}
@ -228,6 +234,56 @@ public class OkHttpJsonApiClient {
});
}
/**
* The method returns the picture of the day
*
* @return Media object corresponding to the picture of the day
*/
@Nullable
public Single<Media> getPictureOfTheDay() {
String date = CommonsDateUtil.getIso8601DateFormatShort().format(new Date());
Timber.d("Current date is %s", date);
String template = "Template:Potd/" + date;
return getMedia(template, true);
}
/**
* Fetches Media object from the imageInfo API
*
* @param titles the tiles to be searched for. Can be filename or template name
* @param useGenerator specifies if a image generator parameter needs to be passed or not
* @return
*/
public Single<Media> getMedia(String titles, boolean useGenerator) {
HttpUrl.Builder urlBuilder = HttpUrl
.parse(commonsBaseUrl)
.newBuilder()
.addQueryParameter("action", "query")
.addQueryParameter("format", "json")
.addQueryParameter("formatversion", "2")
.addQueryParameter("titles", titles);
if (useGenerator) {
urlBuilder.addQueryParameter("generator", "images");
}
Request request = new Request.Builder()
.url(appendMediaProperties(urlBuilder).build())
.build();
return Single.fromCallable(() -> {
Response response = okHttpClient.newCall(request).execute();
if (response.body() != null && response.isSuccessful()) {
String json = response.body().string();
MwQueryResponse mwQueryPage = gson.fromJson(json, MwQueryResponse.class);
if (mwQueryPage.success() && mwQueryPage.query().firstPage() != null) {
return Media.from(mwQueryPage.query().firstPage());
}
}
return null;
});
}
/**
* Whenever imageInfo is fetched, these common properties can be specified for the API call
* https://www.mediawiki.org/wiki/API:Imageinfo
@ -248,4 +304,124 @@ public class OkHttpJsonApiClient {
return builder;
}
/**
* This method takes the keyword and queryType as input and returns a list of Media objects filtered using image generator query
* It uses the generator query API to get the images searched using a query, 10 at a time.
* @param queryType queryType can be "search" OR "category"
* @param keyword the search keyword. Can be either category name or search query
* @return
*/
@Nullable
public Single<List<Media>> getMediaList(String queryType, String keyword) {
HttpUrl.Builder urlBuilder = HttpUrl
.parse(commonsBaseUrl)
.newBuilder()
.addQueryParameter("action", "query")
.addQueryParameter("format", "json")
.addQueryParameter("formatversion", "2");
if (queryType.equals("search")) {
appendSearchParam(keyword, urlBuilder);
} else {
appendCategoryParams(keyword, urlBuilder);
}
appendQueryContinueValues(keyword, urlBuilder);
Request request = new Request.Builder()
.url(appendMediaProperties(urlBuilder).build())
.build();
return Single.fromCallable(() -> {
Response response = okHttpClient.newCall(request).execute();
List<Media> mediaList = new ArrayList<>();
if (response.body() != null && response.isSuccessful()) {
String json = response.body().string();
MwQueryResponse mwQueryResponse = gson.fromJson(json, MwQueryResponse.class);
if (null == mwQueryResponse
|| null == mwQueryResponse.query()
|| null == mwQueryResponse.query().pages()) {
return mediaList;
}
putContinueValues(keyword, mwQueryResponse.continuation());
List<MwQueryPage> pages = mwQueryResponse.query().pages();
for (MwQueryPage page : pages) {
Media media = Media.from(page);
if (media != null) {
mediaList.add(media);
}
}
}
return mediaList;
});
}
/**
* Append params for search query.
*
* @param query the search query to be sent to the API
* @param urlBuilder builder for HttpUrl
*/
private void appendSearchParam(String query, HttpUrl.Builder urlBuilder) {
urlBuilder.addQueryParameter("generator", "search")
.addQueryParameter("gsrwhat", "text")
.addQueryParameter("gsrnamespace", "6")
.addQueryParameter("gsrlimit", "25")
.addQueryParameter("gsrsearch", query);
}
/**
* It takes a urlBuilder and appends all the continue values as query parameters
*
* @param query
* @param urlBuilder
*/
private void appendQueryContinueValues(String query, HttpUrl.Builder urlBuilder) {
Map<String, String> continueValues = getContinueValues(query);
if (continueValues != null && continueValues.size() > 0) {
for (Map.Entry<String, String> entry : continueValues.entrySet()) {
urlBuilder.addQueryParameter(entry.getKey(), entry.getValue());
}
}
}
/**
* Append parameters for category image generator
*
* @param categoryName name of the category
* @param urlBuilder HttpUrl builder
*/
private void appendCategoryParams(String categoryName, HttpUrl.Builder urlBuilder) {
urlBuilder.addQueryParameter("generator", "categorymembers")
.addQueryParameter("gcmtype", "file")
.addQueryParameter("gcmtitle", categoryName)
.addQueryParameter("gcmsort", "timestamp")//property to sort by;timestamp
.addQueryParameter("gcmdir", "desc")//in which direction to sort;descending
.addQueryParameter("gcmlimit", "10");
}
/**
* Stores the continue values for action=query
* These values are sent to the server in the subsequent call to fetch results after this point
*
* @param keyword
* @param values
*/
private void putContinueValues(String keyword, Map<String, String> values) {
defaultKvStore.putJson("query_continue_" + keyword, values);
}
/**
* Retrieves a map of continue values from shared preferences.
* These values are appended to the next API call
*
* @param keyword
* @return
*/
private Map<String, String> getContinueValues(String keyword) {
return defaultKvStore.getJson("query_continue_" + keyword, mapType);
}
}

View file

@ -0,0 +1,90 @@
package fr.free.nrw.commons.mwapi;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
public class UploadResult {
private String errorCode;
private String resultStatus;
private Date dateUploaded;
private String imageUrl;
private String canonicalFilename;
/**
* Minimal constructor
*
* @param resultStatus Upload result status
* @param errorCode Upload error code
*/
UploadResult(String resultStatus, String errorCode) {
this.resultStatus = resultStatus;
this.errorCode = errorCode;
}
/**
* Full-fledged constructor
* @param resultStatus Upload result status
* @param dateUploaded Uploaded date
* @param canonicalFilename Uploaded file name
* @param imageUrl Uploaded image file name
*/
UploadResult(String resultStatus, Date dateUploaded, String canonicalFilename, String imageUrl) {
this.resultStatus = resultStatus;
this.dateUploaded = dateUploaded;
this.canonicalFilename = canonicalFilename;
this.imageUrl = imageUrl;
}
@NotNull
@Override
public String toString() {
return "UploadResult{" +
"errorCode='" + errorCode + '\'' +
", resultStatus='" + resultStatus + '\'' +
", dateUploaded='" + (dateUploaded == null ? "" : dateUploaded.toString()) + '\'' +
", imageUrl='" + imageUrl + '\'' +
", canonicalFilename='" + canonicalFilename + '\'' +
'}';
}
/**
* Gets uploaded date
* @return Upload date
*/
public Date getDateUploaded() {
return dateUploaded;
}
/**
* Gets image url
* @return Uploaded image url
*/
public String getImageUrl() {
return imageUrl;
}
/**
* Gets canonical file name
* @return Uploaded file name
*/
public String getCanonicalFilename() {
return canonicalFilename;
}
/**
* Gets upload error code
* @return Error code
*/
public String getErrorCode() {
return errorCode;
}
/**
* Gets upload result status
* @return Upload result status
*/
public String getResultStatus() {
return resultStatus;
}
}

View file

@ -0,0 +1,70 @@
package fr.free.nrw.commons.mwapi;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class UploadStash {
@NonNull
private String errorCode;
@NonNull
private String resultStatus;
@NonNull
private String filename;
@NonNull
private String filekey;
@NonNull
public final String getErrorCode() {
return this.errorCode;
}
@NonNull
public final String getResultStatus() {
return this.resultStatus;
}
@NonNull
public final String getFilename() {
return this.filename;
}
@NonNull
public final String getFilekey() {
return this.filekey;
}
public UploadStash(@NonNull String errorCode, @NonNull String resultStatus, @NonNull String filename, @NonNull String filekey) {
this.errorCode = errorCode;
this.resultStatus = resultStatus;
this.filename = filename;
this.filekey = filekey;
}
public String toString() {
return "UploadStash(errorCode=" + this.errorCode + ", resultStatus=" + this.resultStatus + ", filename=" + this.filename + ", filekey=" + this.filekey + ")";
}
public int hashCode() {
return ((this.errorCode.hashCode() * 31 + this.resultStatus.hashCode()
) * 31 + this.filename.hashCode()
) * 31 + this.filekey.hashCode();
}
public boolean equals(@Nullable Object obj) {
if (this != obj) {
if (obj instanceof UploadStash) {
UploadStash that = (UploadStash)obj;
if (this.errorCode.equals(that.errorCode)
&& this.resultStatus.equals(that.resultStatus)
&& this.filename.equals(that.filename)
&& this.filekey.equals(that.filekey)) {
return true;
}
}
return false;
} else {
return true;
}
}
}