Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
7459 lines (6934 sloc) 282 KB
/**
*
* This file is part of the https://github.com/WolfgangFahl/Mediawiki-Japi open source project
*
* Copyright 2015-2018 BITPlan GmbH https://github.com/BITPlan
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* You may obtain a copy of the License at
*
* http:www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.wikipedia;
import java.io.*;
import java.net.*;
import java.text.Normalizer;
import java.util.*;
import java.util.logging.*;
import java.util.zip.GZIPInputStream;
import javax.security.auth.login.*;
/**
* This is a somewhat sketchy bot framework for editing MediaWiki wikis.
* Requires JDK 1.7 or greater. Uses the <a
* href="//www.mediawiki.org/wiki/API:Main_page">MediaWiki API</a> for most
* operations. It is recommended that the server runs the latest version
* of MediaWiki (1.23), otherwise some functions may not work.
* <p>
* Extended documentation is available
* <a href="//github.com/MER-C/wiki-java/wiki/Extended-documentation">here</a>.
* All wikilinks are relative to the English Wikipedia and all timestamps are in
* your wiki's time zone.
* </p>
* Please file bug reports <a href="//en.wikipedia.org/wiki/User_talk:MER-C">here</a>
* or at the <a href="//github.com/MER-C/wiki-java/issues">Github issue tracker</a>.
*
* @author MER-C and contributors
* @version 0.30
*/
public class Wiki implements Serializable
{
// Master TODO list:
// *Admin stuff
// *More multiqueries
// *Generators (hard)
// NAMESPACES
/**
* Denotes the namespace of images and media, such that there is no
* description page. Uses the "Media:" prefix.
* @see #FILE_NAMESPACE
* @since 0.03
*/
public static final int MEDIA_NAMESPACE = -2;
/**
* Denotes the namespace of pages with the "Special:" prefix. Note
* that many methods dealing with special pages may spew due to
* raw content not being available.
* @since 0.03
*/
public static final int SPECIAL_NAMESPACE = -1;
/**
* Denotes the main namespace, with no prefix.
* @since 0.03
*/
public static final int MAIN_NAMESPACE = 0;
/**
* Denotes the namespace for talk pages relating to the main
* namespace, denoted by the prefix "Talk:".
* @since 0.03
*/
public static final int TALK_NAMESPACE = 1;
/**
* Denotes the namespace for user pages, given the prefix "User:".
* @since 0.03
*/
public static final int USER_NAMESPACE = 2;
/**
* Denotes the namespace for user talk pages, given the prefix
* "User talk:".
* @since 0.03
*/
public static final int USER_TALK_NAMESPACE = 3;
/**
* Denotes the namespace for pages relating to the project,
* with prefix "Project:". It also goes by the name of whatever
* the project name was.
* @since 0.03
*/
public static final int PROJECT_NAMESPACE = 4;
/**
* Denotes the namespace for talk pages relating to project
* pages, with prefix "Project talk:". It also goes by the name
* of whatever the project name was, + "talk:".
* @since 0.03
*/
public static final int PROJECT_TALK_NAMESPACE = 5;
/**
* Denotes the namespace for file description pages. Has the prefix
* "File:". Do not create these directly, use upload() instead.
* @see #MEDIA_NAMESPACE
* @since 0.25
*/
public static final int FILE_NAMESPACE = 6;
/**
* Denotes talk pages for file description pages. Has the prefix
* "File talk:".
* @since 0.25
*/
public static final int FILE_TALK_NAMESPACE = 7;
/**
* Denotes the namespace for (wiki) system messages, given the prefix
* "MediaWiki:".
* @since 0.03
*/
public static final int MEDIAWIKI_NAMESPACE = 8;
/**
* Denotes the namespace for talk pages relating to system messages,
* given the prefix "MediaWiki talk:".
* @since 0.03
*/
public static final int MEDIAWIKI_TALK_NAMESPACE = 9;
/**
* Denotes the namespace for templates, given the prefix "Template:".
* @since 0.03
*/
public static final int TEMPLATE_NAMESPACE = 10;
/**
* Denotes the namespace for talk pages regarding templates, given
* the prefix "Template talk:".
* @since 0.03
*/
public static final int TEMPLATE_TALK_NAMESPACE = 11;
/**
* Denotes the namespace for help pages, given the prefix "Help:".
* @since 0.03
*/
public static final int HELP_NAMESPACE = 12;
/**
* Denotes the namespace for talk pages regarding help pages, given
* the prefix "Help talk:".
* @since 0.03
*/
public static final int HELP_TALK_NAMESPACE = 13;
/**
* Denotes the namespace for category description pages. Has the
* prefix "Category:".
* @since 0.03
*/
public static final int CATEGORY_NAMESPACE = 14;
/**
* Denotes the namespace for talk pages regarding categories. Has the
* prefix "Category talk:".
* @since 0.03
*/
public static final int CATEGORY_TALK_NAMESPACE = 15;
/**
* Denotes all namespaces.
* @since 0.03
*/
public static final int ALL_NAMESPACES = 0x09f91102;
// LOG TYPES
/**
* Denotes all logs.
* @since 0.06
*/
public static final String ALL_LOGS = "";
/**
* Denotes the user creation log.
* @since 0.06
*/
public static final String USER_CREATION_LOG = "newusers";
/**
* Denotes the upload log.
* @since 0.06
*/
public static final String UPLOAD_LOG = "upload";
/**
* Denotes the deletion log.
* @since 0.06
*/
public static final String DELETION_LOG = "delete";
/**
* Denotes the move log.
* @since 0.06
*/
public static final String MOVE_LOG = "move";
/**
* Denotes the block log.
* @since 0.06
*/
public static final String BLOCK_LOG = "block";
/**
* Denotes the protection log.
* @since 0.06
*/
public static final String PROTECTION_LOG = "protect";
/**
* Denotes the user rights log.
* @since 0.06
*/
public static final String USER_RIGHTS_LOG = "rights";
/**
* Denotes the user renaming log.
* @since 0.06
*/
public static final String USER_RENAME_LOG = "renameuser";
/**
* Denotes the page importation log.
* @since 0.08
*/
public static final String IMPORT_LOG = "import";
/**
* Denotes the edit patrol log.
* @since 0.08
*/
public static final String PATROL_LOG = "patrol";
// PROTECTION LEVELS
/**
* Denotes a non-protected page.
* @since 0.09
*/
public static final String NO_PROTECTION = "all";
/**
* Denotes semi-protection (i.e. only autoconfirmed users can perform a
* particular action).
* @since 0.09
*/
public static final String SEMI_PROTECTION = "autoconfirmed";
/**
* Denotes full protection (i.e. only admins can perfom a particular action).
* @since 0.09
*/
public static final String FULL_PROTECTION = "sysop";
// ASSERTION MODES
/**
* Use no assertions (i.e. 0).
* @see #setAssertionMode
* @since 0.11
*/
public static final int ASSERT_NONE = 0;
/**
* Assert that we are logged in (i.e. 1). This is checked every action.
* @see #setAssertionMode
* @since 0.30
*/
public static final int ASSERT_USER = 1;
/**
* Assert that we have a bot flag (i.e. 2). This is checked every action.
* @see #setAssertionMode
* @since 0.11
*/
public static final int ASSERT_BOT = 2;
/**
* Assert that we have no new messages. Not defined officially, but
* some bots have this. This is checked intermittently.
* @see #setAssertionMode
* @since 0.11
*/
public static final int ASSERT_NO_MESSAGES = 4;
/**
* Assert that we have a sysop flag (i.e. 8). This is checked intermittently.
* @see #setAssertionMode
* @since 0.30
*/
public static final int ASSERT_SYSOP = 8;
// RC OPTIONS
/**
* In queries against the recent changes table, this would mean we don't
* fetch anonymous edits.
* @since 0.20
*/
public static final int HIDE_ANON = 1;
/**
* In queries against the recent changes table, this would mean we don't
* fetch edits made by bots.
* @since 0.20
*/
public static final int HIDE_BOT = 2;
/**
* In queries against the recent changes table, this would mean we don't
* fetch by the logged in user.
* @since 0.20
*/
public static final int HIDE_SELF = 4;
/**
* In queries against the recent changes table, this would mean we don't
* fetch minor edits.
* @since 0.20
*/
public static final int HIDE_MINOR = 8;
/**
* In queries against the recent changes table, this would mean we don't
* fetch patrolled edits.
* @since 0.20
*/
public static final int HIDE_PATROLLED = 16;
// REVISION OPTIONS
/**
* In <tt>Revision.diff()</tt>, denotes the next revision.
* @see org.wikipedia.Wiki.Revision#diff(org.wikipedia.Wiki.Revision)
* @since 0.21
*/
public static final long NEXT_REVISION = -1L;
/**
* In <tt>Revision.diff()</tt>, denotes the current revision.
* @see org.wikipedia.Wiki.Revision#diff(org.wikipedia.Wiki.Revision)
* @since 0.21
*/
public static final long CURRENT_REVISION = -2L;
/**
* In <tt>Revision.diff()</tt>, denotes the previous revision.
* @see org.wikipedia.Wiki.Revision#diff(org.wikipedia.Wiki.Revision)
* @since 0.21
*/
public static final long PREVIOUS_REVISION = -3L;
/**
* The list of options the user can specify for his/her gender.
* @since 0.24
*/
public enum Gender
{
// These names come from the MW API so we can use valueOf() and
// toString() without any fidgets whatsoever. Java naming conventions
// aren't worth another 20 lines of code.
/**
* The user self-identifies as a male.
* @since 0.24
*/
male,
/**
* The user self-identifies as a female.
* @since 0.24
*/
female,
/**
* The user has not specified a gender in preferences.
* @since 0.24
*/
unknown;
}
private static final String version = "0.30";
// the domain of the wiki
private String domain;
protected String query, base, apiUrl;
protected String scriptPath = "/w";
private boolean wgCapitalLinks = true;
private String timezone = "UTC";
// user management
private Map<String, String> cookies = new HashMap<>(12);
User user;
private int statuscounter = 0;
// various caches
private transient LinkedHashMap<String, Integer> namespaces = null;
private transient List<String> watchlist = null;
// preferences
private int max = 500;
private int slowmax = 50;
private int throttle = 10000; // throttle
private int maxlag = 5;
private int assertion = ASSERT_NONE; // assertion mode
private transient int statusinterval = 100; // status check
private String useragent = "Wiki.java " + version + "(https://github.com/MER-C/wiki-java/)";
private boolean zipped = true;
private boolean markminor = false, markbot = false;
private boolean resolveredirect = false;
private String protocol = "https://";
private Level loglevel = Level.ALL;
private static final Logger logger = Logger.getLogger("wiki");
// retry flag
private boolean retry = true;
// serial version
private static final long serialVersionUID = -8745212681497643456L;
// time to open a connection
private static final int CONNECTION_CONNECT_TIMEOUT_MSEC = 30000; // 30 seconds
// time for the read to take place. (needs to be longer, some connections are slow
// and the data volume is large!)
private static final int CONNECTION_READ_TIMEOUT_MSEC = 180000; // 180 seconds
// log2(upload chunk size). Default = 22 => upload size = 4 MB. Disable
// chunked uploads by setting a large value here (50 = 1 PB will do).
private static final int LOG2_CHUNK_SIZE = 22;
// CONSTRUCTORS AND CONFIGURATION
/**
* Creates a new connection to the English Wikipedia via HTTPS.
* @since 0.02
*/
public Wiki()
{
this("en.wikipedia.org", "/w");
}
/**
* Creates a new connection to a wiki via HTTPS. WARNING: if the wiki uses
* a $wgScriptpath other than the default <tt>/w</tt>, you need to call
* <tt>getScriptPath()</tt> to automatically set it. Alternatively, you
* can use the constructor below if you know it in advance.
*
* @param domain the wiki domain name e.g. en.wikipedia.org (defaults to
* en.wikipedia.org)
*/
public Wiki(String domain)
{
this(domain, "/w");
}
/**
* Creates a new connection to a wiki with $wgScriptpath set to
* <tt>scriptPath</tt> via HTTPS.
*
* @param domain the wiki domain name
* @param scriptPath the script path
* @since 0.14
*/
public Wiki(String domain, String scriptPath)
{
this(domain, scriptPath, "https://");
}
/**
* Creates a new connection to a wiki with $wgScriptpath set to
* <tt>scriptPath</tt> via the specified protocol.
*
* @param domain the wiki domain name
* @param scriptPath the script path
* @param protocol a protocol e.g. "http://", "https://" or "file:///"
* @since 0.31
*/
public Wiki(String domain, String scriptPath, String protocol)
{
if (domain == null || domain.isEmpty())
domain = "en.wikipedia.org";
this.domain = domain;
this.scriptPath = scriptPath;
this.protocol = protocol;
// init variables
// This is fine as long as you do not have parameters other than domain
// and scriptpath in constructors and do not do anything else than super(x)!
// http://stackoverflow.com/questions/3404301/whats-wrong-with-overridable-method-calls-in-constructors
// TODO: make this more sane.
logger.setLevel(loglevel);
log(Level.CONFIG, "<init>", "Using Wiki.java " + version);
initVars();
}
/**
* Edit this if you need to change the API and human interface url
* configuration of the wiki. One example use is server-side cache
* management (maxage and smaxage API parameters).
*
* <p>Contributed by Tedder
* @since 0.24
*/
protected void initVars()
{
StringBuilder basegen = new StringBuilder(protocol);
basegen.append(domain);
basegen.append(scriptPath);
StringBuilder apigen = new StringBuilder(basegen);
apigen.append("/api.php?format=xml&rawcontinue=1&");
// MediaWiki has inbuilt maxlag functionality, see [[mw:Manual:Maxlag
// parameter]]. Let's exploit it.
if (maxlag >= 0)
{
apigen.append("maxlag=");
apigen.append(maxlag);
apigen.append("&");
basegen.append("/index.php?maxlag=");
basegen.append(maxlag);
basegen.append("&title=");
}
else
basegen.append("/index.php?title=");
base = basegen.toString();
// the native API supports assertions as of MW 1.23
if ((assertion & ASSERT_BOT) == ASSERT_BOT)
apigen.append("assert=bot&");
else if ((assertion & ASSERT_USER) == ASSERT_USER)
apigen.append("assert=user&");
apiUrl = apigen.toString();
apigen.append("action=query&");
if (resolveredirect)
apigen.append("redirects&");
query = apigen.toString();
}
/**
* Gets the domain of the wiki, as supplied on construction.
* @return the domain of the wiki
* @since 0.06
*/
public String getDomain()
{
return domain;
}
/**
* Gets the editing throttle.
* @return the throttle value in milliseconds
* @see #setThrottle
* @since 0.09
*/
public int getThrottle()
{
return throttle;
}
/**
* Sets the editing throttle. Read requests are not throttled or restricted
* in any way. Default is 10s.
* @param throttle the new throttle value in milliseconds
* @see #getThrottle
* @since 0.09
*/
public void setThrottle(int throttle)
{
this.throttle = throttle;
log(Level.CONFIG, "setThrottle", "Throttle set to " + throttle + " milliseconds");
}
/**
* Detects the $wgScriptpath wiki variable and sets the bot framework up
* to use it. You need not call this if you know the script path is
* <tt>/w</tt>. See also [[mw:Manual:$wgScriptpath]].
*
* @throws IOException if a network error occurs
* @deprecated use getSiteInfo
* @return the script path, if you have any use for it
* @since 0.14
*/
@Deprecated
public String getScriptPath() throws IOException
{
return (String)getSiteInfo().get("scriptpath");
}
/**
* Detects whether a wiki forces upper case for the first character in a
* title and sets the bot framework up to use it. Example: en.wikipedia =
* true, en.wiktionary = false. Default = true. See [[mw:Manual:$wgCapitalLinks]].
* @return see above
* @deprecated use getSiteInfo
* @throws IOException if a network error occurs
* @since 0.30
*/
@Deprecated
public boolean isUsingCapitalLinks() throws IOException
{
return (Boolean)getSiteInfo().get("usingcapitallinks");
}
/**
* Gets various properties of the wiki and sets the bot framework up to use
* them. Returns:
* <ul>
* <li><b>usingcapitallinks</b>: (Boolean) whether a wiki forces upper case
* for the title. Example: en.wikipedia = true, en.wiktionary = false.
* Default = true. See [[mw:Manual:$wgCapitalLinks]].
* <li><b>scriptpath</b>: (String) the $wgScriptpath wiki variable. Default
* = <tt>/w</tt>. See [[mw:Manual:$wgScriptpath]].
* <li><b>version</b>: (String) the MediaWiki version used for this wiki
* <li><b>timezone</b>: (String) the timezone the wiki is in, default = UTC
* </ul>
*
* @return (see above)
* @since 0.30
* @throws IOException if a network error occurs
*/
public Map<String, Object> getSiteInfo() throws IOException
{
Map<String, Object> ret = new HashMap<>();
String line = fetch(query + "action=query&meta=siteinfo", "getSiteInfo");
wgCapitalLinks = parseAttribute(line, "case", 0).equals("first-letter");
ret.put("usingcapitallinks", wgCapitalLinks);
scriptPath = parseAttribute(line, "scriptpath", 0);
ret.put("scriptpath", scriptPath);
timezone = parseAttribute(line, "timezone", 0);
ret.put("timezone", timezone);
ret.put("version", parseAttribute(line, "generator", 0));
initVars();
return ret;
}
/**
* Sets the user agent HTTP header to be used for requests. Default is
* "Wiki.java " + version.
* @param useragent the new user agent
* @since 0.22
*/
public void setUserAgent(String useragent)
{
this.useragent = useragent;
}
/**
* Gets the user agent HTTP header to be used for requests. Default is
* "Wiki.java " + version.
* @return useragent the user agent
* @since 0.22
*/
public String getUserAgent()
{
return useragent;
}
/**
* Enables/disables GZip compression for GET requests. Default: true.
* @param zipped whether we use GZip compression
* @since 0.23
*/
public void setUsingCompressedRequests(boolean zipped)
{
this.zipped = zipped;
}
/**
* Checks whether we are using GZip compression for GET requests.
* Default: true.
* @return (see above)
* @since 0.23
*/
public boolean isUsingCompressedRequests()
{
return zipped;
}
/**
* Checks whether API action=query dependencies automatically resolve
* redirects (default = false).
* @return (see above)
* @since 0.27
*/
public boolean isResolvingRedirects()
{
return resolveredirect;
}
/**
* Sets whether API action=query dependencies automatically resolve
* redirects (default = false).
* @param b (see above)
* @since 0.27
*/
public void setResolveRedirects(boolean b)
{
resolveredirect = b;
initVars();
}
/**
* Sets whether edits are marked as bot by default (may be overridden
* specifically by edit()). Default = false. Works only if one has the
* required permissions.
* @param markbot (see above)
* @since 0.26
*/
public void setMarkBot(boolean markbot)
{
this.markbot = markbot;
}
/**
* Are edits are marked as bot by default?
* @return whether edits are marked as bot by default
* @since 0.26
*/
public boolean isMarkBot()
{
return markbot;
}
/**
* Sets whether edits are marked as minor by default (may be overridden
* specifically by edit()). Default = false.
* @param minor (see above)
* @since 0.26
*/
public void setMarkMinor(boolean minor)
{
this.markminor = minor;
}
/**
* Are edits are marked as minor by default?
* @return whether edits are marked as minor by default
* @since 0.26
*/
public boolean isMarkMinor()
{
return markminor;
}
/**
* Determines whether this wiki is equal to another object.
* @param obj the object to compare
* @return whether this wiki is equal to such object
* @since 0.10
*/
@Override
public boolean equals(Object obj)
{
if (!(obj instanceof Wiki))
return false;
return domain.equals(((Wiki)obj).domain);
}
/**
* Returns a hash code of this object.
* @return a hash code
* @since 0.12
*/
@Override
public int hashCode()
{
return domain.hashCode() * maxlag - throttle;
}
/**
* Returns a string representation of this Wiki.
* @return a string representation of this Wiki.
* @since 0.10
*/
@Override
public String toString()
{
// domain
StringBuilder buffer = new StringBuilder("Wiki[domain=");
buffer.append(domain);
// user
buffer.append(",user=");
buffer.append(user != null ? user.toString() : "null");
buffer.append(",");
// throttle mechanisms
buffer.append("throttle=");
buffer.append(throttle);
buffer.append(",maxlag=");
buffer.append(maxlag);
buffer.append(",assertionMode=");
buffer.append(assertion);
buffer.append(",statusCheckInterval=");
buffer.append(statusinterval);
buffer.append(",cookies=");
buffer.append(cookies);
buffer.append("]");
return buffer.toString();
}
/**
* Gets the maxlag parameter. See [[mw:Manual:Maxlag parameter]].
* @return the current maxlag, in seconds
* @see #setMaxLag
* @see #getCurrentDatabaseLag
* @since 0.11
*/
public int getMaxLag()
{
return maxlag;
}
/**
* Sets the maxlag parameter. A value of less than 0s disables this
* mechanism. Default is 5s.
* @param lag the desired maxlag in seconds
* @see #getMaxLag
* @see #getCurrentDatabaseLag
* @since 0.11
*/
public void setMaxLag(int lag)
{
maxlag = lag;
log(Level.CONFIG, "setMaxLag", "Setting maximum allowable database lag to " + lag);
initVars();
}
/**
* Gets the assertion mode. Assertion modes are bitmasks.
* @return the current assertion mode
* @see #setAssertionMode
* @since 0.11
*/
public int getAssertionMode()
{
return assertion;
}
/**
* Sets the assertion mode. Do this AFTER logging in, otherwise the login
* will fail. Assertion modes are bitmasks. Default is <tt>ASSERT_NONE</tt>.
* @param mode an assertion mode
* @see #getAssertionMode
* @since 0.11
*/
public void setAssertionMode(int mode)
{
assertion = mode;
log(Level.CONFIG, "setAssertionMode", "Set assertion mode to " + mode);
initVars();
}
/**
* Gets the number of actions (edit, move, block, delete, etc) between
* status checks. A status check is where we update user rights, block
* status and check for new messages (if the appropriate assertion mode
* is set).
*
* @return the number of edits between status checks
* @see #setStatusCheckInterval
* @since 0.18
*/
public int getStatusCheckInterval()
{
return statusinterval;
}
/**
* Sets the number of actions (edit, move, block, delete, etc) between
* status checks. A status check is where we update user rights, block
* status and check for new messages (if the appropriate assertion mode
* is set). Default is 100.
*
* @param interval the number of edits between status checks
* @see #getStatusCheckInterval
* @since 0.18
*/
public void setStatusCheckInterval(int interval)
{
statusinterval = interval;
log(Level.CONFIG, "setStatusCheckInterval", "Status check interval set to " + interval);
}
/**
* Set the logging level used by the internal logger.
* @param loglevel one of the levels specified in java.util.logging.LEVEL
* @since 0.31
*/
public void setLogLevel(Level loglevel)
{
this.loglevel = loglevel;
logger.setLevel(loglevel);
}
// META STUFF
/**
* Logs in to the wiki. This method is thread-safe. If the specified
* username or password is incorrect, the thread blocks for 20 seconds
* then throws an exception.
*
* @param username a username
* @param password a password (as a char[] due to JPasswordField)
* @throws FailedLoginException if the login failed due to incorrect
* username and/or password
* @throws IOException if a network error occurs
* @see #logout
*/
public synchronized void login(String username, char[] password) throws IOException, FailedLoginException
{
// post login request
username = normalize(username);
StringBuilder buffer = new StringBuilder(500);
buffer.append("lgname=");
buffer.append(URLEncoder.encode(username, "UTF-8"));
// fetch token
String response = post(apiUrl + "action=login", buffer.toString(), "login");
String wpLoginToken = parseAttribute(response, "token", 0);
buffer.append("&lgpassword=");
buffer.append(URLEncoder.encode(new String(password), "UTF-8"));
buffer.append("&lgtoken=");
buffer.append(URLEncoder.encode(wpLoginToken, "UTF-8"));
String line = post(apiUrl + "action=login", buffer.toString(), "login");
buffer = null;
// check for success
if (line.contains("result=\"Success\""))
{
user = new User(username);
boolean apihighlimit = user.isAllowedTo("apihighlimits");
if (apihighlimit)
{
max = 5000;
slowmax = 500;
}
log(Level.INFO, "login", "Successfully logged in as " + username + ", highLimit = " + apihighlimit);
}
else
{
log(Level.WARNING, "login", "Failed to log in as " + username);
try
{
Thread.sleep(20000); // to prevent brute force
}
catch (InterruptedException e)
{
// nobody cares
}
// test for some common failure reasons
if (line.contains("WrongPass") || line.contains("WrongPluginPass"))
throw new FailedLoginException("Login failed: incorrect password.");
else if (line.contains("NotExists"))
throw new FailedLoginException("Login failed: user does not exist.");
throw new FailedLoginException("Login failed: unknown reason.");
}
}
//Enables login while using a string password
public synchronized void login(String username, String password) throws IOException, FailedLoginException
{
login(username,password.toCharArray());
}
/**
* Logs out of the wiki. This method is thread safe (so that we don't log
* out during an edit). All operations are conducted offline, so you can
* serialize this Wiki first.
* @see #login
* @see #logoutServerSide
*/
public synchronized void logout()
{
cookies.clear();
user = null;
max = 500;
slowmax = 50;
log(Level.INFO, "logout", "Logged out");
}
/**
* Logs out of the wiki and destroys the session on the server. You will
* need to log in again instead of just reading in a serialized wiki.
* Equivalent to [[Special:Userlogout]]. This method is thread safe
* (so that we don't log out during an edit). WARNING: kills all
* concurrent sessions - if you are logged in with a browser this will log
* you out there as well.
*
* @throws IOException if a network error occurs
* @since 0.14
* @see #login
* @see #logout
*/
public synchronized void logoutServerSide() throws IOException
{
fetch(apiUrl + "action=logout", "logoutServerSide");
logout(); // destroy local cookies
}
/**
* Determines whether the current user has new messages. (A human would
* notice a yellow bar at the top of the page).
* @return whether the user has new messages
* @throws IOException if a network error occurs
* @since 0.11
*/
public boolean hasNewMessages() throws IOException
{
String url = query + "meta=userinfo&uiprop=hasmsg";
return fetch(url, "hasNewMessages").contains("messages=\"\"");
}
/**
* Determines the current database replication lag.
* @return the current database replication lag
* @throws IOException if a network error occurs
* @see #setMaxLag
* @see #getMaxLag
* @since 0.10
*/
public int getCurrentDatabaseLag() throws IOException
{
String line = fetch(query + "meta=siteinfo&siprop=dbrepllag", "getCurrentDatabaseLag");
String lag = parseAttribute(line, "lag", 0);
log(Level.INFO, "getCurrentDatabaseLag", "Current database replication lag is " + lag + " seconds");
return Integer.parseInt(lag);
}
/**
* Fetches some site statistics, namely the number of articles, pages,
* files, edits, users and admins. Equivalent to [[Special:Statistics]].
*
* @return a map containing the stats. Use "articles", "pages", "files"
* "edits", "users", "activeusers", "admins" or "jobs" to retrieve the
* respective value
* @throws IOException if a network error occurs
* @since 0.14
*/
public Map<String, Integer> getSiteStatistics() throws IOException
{
String text = fetch(query + "meta=siteinfo&siprop=statistics", "getSiteStatistics");
Map<String, Integer> ret = new HashMap<>(20);
ret.put("pages", Integer.parseInt(parseAttribute(text, "pages", 0)));
ret.put("articles", Integer.parseInt(parseAttribute(text, "articles", 0)));
ret.put("files", Integer.parseInt(parseAttribute(text, "images", 0)));
ret.put("users", Integer.parseInt(parseAttribute(text, "users", 0)));
ret.put("activeusers", Integer.parseInt(parseAttribute(text, "activeusers", 0)));
ret.put("admins", Integer.parseInt(parseAttribute(text, "admins", 0)));
ret.put("jobs", Integer.parseInt(parseAttribute(text, "jobs", 0))); // job queue length
return ret;
}
/**
* Gets the version of MediaWiki this wiki runs e.g. 1.20wmf5 (54b4fcb).
* See also https://gerrit.wikimedia.org/ .
* @return the version of MediaWiki used
* @throws IOException if a network error occurs
* @deprecated use getSiteInfo
* @since 0.14
*/
@Deprecated
public String version() throws IOException
{
return (String)getSiteInfo().get("version");
}
/**
* Renders the specified wiki markup by passing it to the MediaWiki
* parser through the API. (Note: this isn't implemented locally because
* I can't be stuffed porting Parser.php). One use of this method is to
* emulate the previewing functionality of the MediaWiki software.
*
* @param markup the markup to parse
* @return the parsed markup as HTML
* @throws IOException if a network error occurs
* @since 0.13
*/
public String parse(String markup) throws IOException
{
// This is POST because markup can be arbitrarily large, as in the size
// of an article (over 10kb).
String response = post(apiUrl + "action=parse", "prop=text&text=" + URLEncoder.encode(markup, "UTF-8"), "parse");
int y = response.indexOf('>', response.indexOf("<text")) + 1;
int z = response.indexOf("</text>");
return decode(response.substring(y, z));
}
/**
* Same as <tt>parse()</tt>, but also strips out unwanted crap. This might
* be useful to subclasses.
*
* @param in the string to parse
* @return that string without the crap
* @throws IOException if a network error occurs
* @since 0.14
*/
protected String parseAndCleanup(String in) throws IOException
{
String output = parse(in);
output = output.replace("<p>", "").replace("</p>", ""); // remove paragraph tags
output = output.replace("\n", ""); // remove new lines
// strip out the parser report, which comes at the end
int a = output.indexOf("<!--");
return output.substring(0, a);
}
/**
* Fetches a random page in the main namespace. Equivalent to
* [[Special:Random]].
* @return the title of the page
* @throws IOException if a network error occurs
* @since 0.13
*/
public String random() throws IOException
{
return random(MAIN_NAMESPACE);
}
/**
* Fetches a random page in the specified namespace. Equivalent to
* [[Special:Random]].
*
* @param ns namespace(s)
* @return the title of the page
* @throws IOException if a network error occurs
* @since 0.13
*/
public String random(int... ns) throws IOException
{
// no bulk queries here because they are deterministic -- [[mw:API:Random]]
StringBuilder url = new StringBuilder(query);
url.append("list=random");
constructNamespaceString(url, "rn", ns);
String line = fetch(url.toString(), "random");
return parseAttribute(line, "title", 0);
}
// STATIC MEMBERS
/**
* Determines the intersection of two lists of pages a and b.
* Such lists might be generated from the various list methods below.
* Examples from the English Wikipedia:
*
* <pre>
* // find all orphaned and unwikified articles
* String[] articles = Wiki.intersection(wikipedia.getCategoryMembers("All orphaned articles", Wiki.MAIN_NAMESPACE),
* wikipedia.getCategoryMembers("All pages needing to be wikified", Wiki.MAIN_NAMESPACE));
*
* // find all (notable) living people who are related to Barack Obama
* String[] people = Wiki.intersection(wikipedia.getCategoryMembers("Living people", Wiki.MAIN_NAMESPACE),
* wikipedia.whatLinksHere("Barack Obama", Wiki.MAIN_NAMESPACE));
* </pre>
*
* @param a a list of pages
* @param b another list of pages
* @return a intersect b (as String[])
* @since 0.04
*/
public static String[] intersection(String[] a, String[] b)
{
// @revised 0.11 to take advantage of Collection.retainAll()
// @revised 0.14 genericised to all page titles, not just category members
List<String> aa = new ArrayList<>(5000); // silly workaroiund
aa.addAll(Arrays.asList(a));
aa.retainAll(Arrays.asList(b));
return aa.toArray(new String[aa.size()]);
}
/**
* Determines the list of articles that are in a but not b, i.e. a \ b.
* This is not the same as b \ a. Such lists might be generated from the
* various lists below. Some examples from the English Wikipedia:
*
* <pre>
* // find all Martian crater articles that do not have an infobox
* String[] articles = Wiki.relativeComplement(wikipedia.getCategoryMembers("Craters on Mars"),
* wikipedia.whatTranscludesHere("Template:MarsGeo-Crater", Wiki.MAIN_NAMESPACE));
*
* // find all images without a description that haven't been tagged "no license"
* String[] images = Wiki.relativeComplement(wikipedia.getCategoryMembers("Images lacking a description"),
* wikipedia.getCategoryMembers("All images with unknown copyright status"));
* </pre>
*
* @param a a list of pages
* @param b another list of pages
* @return a \ b
* @since 0.14
*/
public static String[] relativeComplement(String[] a, String[] b)
{
List<String> aa = new ArrayList<>(5000); // silly workaroiund
aa.addAll(Arrays.asList(a));
aa.removeAll(Arrays.asList(b));
return aa.toArray(new String[aa.size()]);
}
// PAGE METHODS
/**
* Returns the corresponding talk page to this page.
*
* @param title the page title
* @return the name of the talk page corresponding to <tt>title</tt>
* or "" if we cannot recognise it
* @throws IllegalArgumentException if given title is in a talk namespace
* or we try to retrieve the talk page of a Special: or Media: page.
* @throws IOException if a network error occurs
* @since 0.10
*/
public String getTalkPage(String title) throws IOException
{
// It is convention that talk namespaces are the original namespace + 1
// and are odd integers.
int namespace = namespace(title);
if (namespace % 2 == 1)
throw new IllegalArgumentException("Cannot fetch talk page of a talk page!");
if (namespace < 0)
throw new IllegalArgumentException("Special: and Media: pages do not have talk pages!");
if (namespace != MAIN_NAMESPACE) // remove the namespace
title = title.substring(title.indexOf(':') + 1);
return namespaceIdentifier(namespace + 1) + ":" + title;
}
/**
* Gets miscellaneous page info.
* @param page the page to get info for
* @return see {@link #getPageInfo(String[]) }
* @throws IOException if a network error occurs
* @since 0.28
*/
public Map getPageInfo(String page) throws IOException
{
return getPageInfo(new String[] { page } )[0];
}
/**
* Gets miscellaneous page info. Returns:
* <ul>
* <li><b>displaytitle</b>: (String) the title of the page that is actually
* displayed. Example: "iPod"
* <li><b>protection</b>: (Map) the {@link #protect(java.lang.String,
* java.util.Map, java.lang.String) protection state} of the page. Does
* not cover implied protection levels (e.g. MediaWiki namespace).
* <li><b>token</b>: (String) an edit token for the page, must be logged in
* to be non-trivial
* <li><b>exists</b>: (Boolean) whether the page exists
* <li><b>lastpurged</b>: (Calendar) when the page was last purged or null
* if the page does not exist
* <li><b>lastrevid</b>: (Long) the revid of the top revision or -1L if the
* page does not exist
* <li><b>size</b>: (Integer) the size of the page or -1 if the page does
* not exist
* <li><b>pageid</b>: (Long) the id of the page or -1 if the page does not
* exist
* <li><b>timestamp</b>: (Calendar) when this method was called
* <li><b>watchtoken</b>: (String) watchlist token or null if logged out
* <li><b>watchers</b>: (Integer) number of watchers, may be restricted
* </ul>
*
* @param pages the pages to get info for.
* @return (see above). The Maps will come out in the same order as the
* processed array.
* @throws IOException if a network error occurs
* @since 0.23
*/
public Map[] getPageInfo(String[] pages) throws IOException
{
Map[] info = new HashMap[pages.length];
StringBuilder url = new StringBuilder(query);
url.append("prop=info&intoken=edit%7Cwatch&inprop=protection%7Cdisplaytitle%7Cwatchers&titles=");
String[] titles = constructTitleString(pages);
for (String temp : titles)
{
String line = fetch(url.toString() + temp, "getPageInfo");
// form: <page pageid="239098" ns="0" title="BitTorrent" ... >
// <protection />
// </page>
for (int j = line.indexOf("<page "); j > 0; j = line.indexOf("<page ", ++j))
{
int x = line.indexOf("</page>", j);
String item = line.substring(j, x);
Map<String, Object> tempmap = new HashMap<>(15);
// does the page exist?
boolean exists = !item.contains("missing=\"\"");
tempmap.put("exists", exists);
if (exists)
{
tempmap.put("lastpurged", timestampToCalendar(parseAttribute(item, "touched", 0), true));
tempmap.put("lastrevid", Long.parseLong(parseAttribute(item, "lastrevid", 0)));
tempmap.put("size", Integer.parseInt(parseAttribute(item, "length", 0)));
tempmap.put("pageid", Long.parseLong(parseAttribute(item, "pageid", 0)));
}
else
{
tempmap.put("lastedited", null);
tempmap.put("lastrevid", -1L);
tempmap.put("size", -1);
tempmap.put("pageid", -1);
}
// parse protection level
// expected form: <pr type="edit" level="sysop" expiry="infinity" cascade="" />
Map<String, Object> protectionstate = new HashMap<>();
for (int z = item.indexOf("<pr "); z > 0; z = item.indexOf("<pr ", ++z))
{
String type = parseAttribute(item, "type", z);
String level = parseAttribute(item, "level", z);
protectionstate.put(type, level);
//if (level != NO_PROTECTION)
String expiry = parseAttribute(item, "expiry", z);
if (expiry.equals("infinity"))
protectionstate.put(type + "expiry", null);
else
protectionstate.put(type + "expiry", timestampToCalendar(expiry, true));
// protected via cascade
if (item.contains("source=\""))
protectionstate.put("cascadesource", parseAttribute(item, "source", z));
}
// MediaWiki namespace
String parsedtitle = parseAttribute(item, "title", 0);
if (namespace(parsedtitle) == MEDIAWIKI_NAMESPACE)
{
protectionstate.put("edit", FULL_PROTECTION);
protectionstate.put("move", FULL_PROTECTION);
if (!exists)
protectionstate.put("create", FULL_PROTECTION);
}
protectionstate.put("cascade", item.contains("cascade=\"\""));
tempmap.put("protection", protectionstate);
tempmap.put("displaytitle", parseAttribute(item, "displaytitle", 0));
tempmap.put("token", parseAttribute(item, "edittoken", 0));
tempmap.put("timestamp", makeCalendar());
// watchlist token
if (user != null)
tempmap.put("watchtoken", parseAttribute(item, "watchtoken", 0));
// number of watchers
if (item.contains("watchers=\""))
tempmap.put("watchers", Integer.parseInt(parseAttribute(item, "watchers", 0)));
// reorder
for (int i = 0; i < pages.length; i++)
if (normalize(pages[i]).equals(parsedtitle))
info[i] = tempmap;
}
}
log(Level.INFO, "getPageInfo", "Successfully retrieved page info for " + Arrays.toString(pages));
return info;
}
/**
* Returns the namespace a page is in. No need to override this to
* add custom namespaces, though you may want to define static fields e.g.
* <tt>public static final int PORTAL_NAMESPACE = 100;</tt> for the Portal
* namespace on the English Wikipedia.
*
* @param title any valid page name
* @return an integer array containing the namespace of <tt>title</tt>
* @throws IOException if a network error occurs while populating the
* namespace cache
* @see #namespaceIdentifier(int)
* @since 0.03
*/
public int namespace(String title) throws IOException
{
// cache this, as it will be called often
if (namespaces == null)
populateNamespaceCache();
// sanitise
if (!title.contains(":"))
return MAIN_NAMESPACE;
String namespace = title.substring(0, title.indexOf(':'));
// look up the namespace of the page in the namespace cache
if (!namespaces.containsKey(namespace))
return MAIN_NAMESPACE; // For titles like UN:NRV
else
return namespaces.get(namespace);
}
/**
* For a given namespace denoted as an integer, fetch the corresponding
* identification string e.g. <tt>namespaceIdentifier(1)</tt> should return
* "Talk" on en.wp. (This does the exact opposite to <tt>namespace()</tt>).
* Strings returned are always localized.
*
* @param namespace an integer corresponding to a namespace. If it does not
* correspond to a namespace, we assume you mean the main namespace (i.e.
* return "").
* @return the identifier of the namespace
* @throws IOException if the namespace cache has not been populated, and
* a network error occurs when populating it
* @see #namespace(java.lang.String)
* @since 0.25
*/
public String namespaceIdentifier(int namespace) throws IOException
{
if (namespaces == null)
populateNamespaceCache();
// anything we cannot identify is assumed to be in the main namespace
if (!namespaces.containsValue(namespace))
return "";
for (Map.Entry<String, Integer> entry : namespaces.entrySet())
if (entry.getValue().equals(namespace))
return entry.getKey();
return ""; // never reached...
}
/**
* Gets the namespaces used by this wiki.
* @return a map containing e.g. {"Media" => -2, "Special" => -1, ...}.
* Changes in this map do not propagate back to this Wiki object.
* @throws IOException if a network error occurs
* @since 0.28
*/
public LinkedHashMap<String, Integer> getNamespaces() throws IOException
{
if (namespaces == null)
populateNamespaceCache();
return (LinkedHashMap<String, Integer>)namespaces.clone();
}
/**
* Populates the namespace cache.
* @throws IOException if a network error occurs.
* @since 0.25
*/
protected void populateNamespaceCache() throws IOException
{
String line = fetch(query + "meta=siteinfo&siprop=namespaces%7Cnamespacealiases", "namespace");
namespaces = new LinkedHashMap<>(30);
// xml form: <ns id="-2" canonical="Media" ... >Media</ns> or <ns id="0" ... />
for (int a = line.indexOf("<ns "); a > 0; a = line.indexOf("<ns ", ++a))
{
int ns = Integer.parseInt(parseAttribute(line, "id", a));
int b = line.indexOf('>', a) + 1;
int c = line.indexOf('<', b);
// this must be first so that namespaceIdentifier always returns the
// localized name
namespaces.put(normalize(decode(line.substring(b, c))), ns);
String canonicalnamespace = parseAttribute(line, "canonical", a);
if (canonicalnamespace != null) // not present for main namespace
namespaces.put(canonicalnamespace, ns);
}
log(Level.INFO, "namespace", "Successfully retrieved namespace list (" + namespaces.size() + " namespaces)");
}
/**
* Determines whether a series of pages exist.
* @param titles the titles to check.
* @return whether the pages exist, in the same order as the processed array
* @throws IOException if a network error occurs
* @since 0.10
*/
public boolean[] exists(String[] titles) throws IOException
{
boolean[] ret = new boolean[titles.length];
Map[] info = getPageInfo(titles);
for (int i = 0; i < titles.length; i++)
ret[i] = (Boolean)info[i].get("exists");
return ret;
}
/**
* Gets the raw wikicode for a page. WARNING: does not support special
* pages. Check [[User talk:MER-C/Wiki.java#Special page equivalents]]
* for fetching the contents of special pages. Use <tt>getImage()</tt> to
* fetch an image.
*
* @param title the title of the page.
* @return the raw wikicode of a page.
* @throws UnsupportedOperationException if you try to retrieve the text of a
* Special: or Media: page
* @throws FileNotFoundException if the page does not exist
* @throws IOException if a network error occurs
* @see #edit
*/
public String getPageText(String title) throws IOException
{
// pitfall check
if (namespace(title) < 0)
throw new UnsupportedOperationException("Cannot retrieve Special: or Media: pages!");
// go for it
String url = base + URLEncoder.encode(normalize(title), "UTF-8") + "&action=raw";
String temp = fetch(url, "getPageText");
log(Level.INFO, "getPageText", "Successfully retrieved text of " + title);
return temp;
}
/**
* Gets the text of a specific section. Useful for section editing.
* @param title the title of the relevant page
* @param number the section number of the section to retrieve text for
* @return the text of the given section
* @throws IOException if a network error occurs
* @throws UnknownError if the page has less than <tt>number</tt>
* sections
* @since 0.24
*/
public String getSectionText(String title, int number) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=revisions&rvprop=content&titles=");
url.append(URLEncoder.encode(title, "UTF-8"));
url.append("&rvsection=");
url.append(number);
String text = fetch(url.toString(), "getSectionText");
// This is currently broken because fetch() intercepts the API error.
// if (text.contains("code=\"rvnosuchsection\""))
// throw new IllegalArgumentException("There is no section " + number + " in the page " + title);
// if the section does not contain any text, <rev xml:space=\"preserve\">
// will not have a separate closing tag
if (!text.contains("</rev>"))
return "";
int a = text.indexOf("<rev ");
a = text.indexOf("xml:space=\"preserve\">", a) + 21;
int b = text.indexOf("</rev>", a);
return decode(text.substring(a, b));
}
/**
* Gets the contents of a page, rendered in HTML (as opposed to
* wikitext). WARNING: only supports special pages in certain
* circumstances, for example <tt>getRenderedText("Special:Recentchanges")
* </tt> returns the 50 most recent change to the wiki in pretty-print
* HTML. You should test any use of this method on-wiki through the text
* <tt>{{Special:Specialpage}}</tt>. Use <tt>getImage()</tt> to fetch an
* image. Be aware of any transclusion limits, as outlined at
* [[Wikipedia:Template limits]].
*
* @param title the title of the page
* @return the rendered contents of that page
* @throws IOException if a network error occurs
* @since 0.10
*/
public String getRenderedText(String title) throws IOException
{
// @revised 0.13 genericised to parse any wikitext
return parse("{{:" + title + "}}");
}
/**
* Edits a page by setting its text to the supplied value. This method is
* thread safe and blocks for a minimum time as specified by the
* throttle. The edit will be marked bot if <tt>isMarkBot() == true</tt>
* and minor if <tt>isMarkMinor() == true</tt>.
*
* @param text the text of the page
* @param title the title of the page
* @param summary the edit summary. See [[Help:Edit summary]]. Summaries
* longer than 200 characters are truncated server-side.
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialException if page is protected and we can't edit it
* @throws UnsupportedOperationException if you try to edit a Special: or a
* Media: page
* @see #getPageText
*/
public void edit(String title, String text, String summary) throws IOException, LoginException
{
edit(title, text, summary, markminor, markbot, -2, null);
}
/**
* Edits a page by setting its text to the supplied value. This method is
* thread safe and blocks for a minimum time as specified by the
* throttle. The edit will be marked bot if <tt>isMarkBot() == true</tt>
* and minor if <tt>isMarkMinor() == true</tt>.
*
* @param text the text of the page
* @param title the title of the page
* @param summary the edit summary. See [[Help:Edit summary]]. Summaries
* longer than 200 characters are truncated server-side.
* @param basetime the timestamp of the revision on which <tt>text</tt> is
* based, used to check for edit conflicts. <tt>null</tt> disables this.
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialException if page is protected and we can't edit it
* @throws UnsupportedOperationException if you try to edit a Special: or a
* Media: page
* @see #getPageText
*/
public void edit(String title, String text, String summary, Calendar basetime) throws IOException, LoginException
{
edit(title, text, summary, markminor, markbot, -2, basetime);
}
/**
* Edits a page by setting its text to the supplied value. This method is
* thread safe and blocks for a minimum time as specified by the
* throttle. The edit will be marked bot if <tt>isMarkBot() == true</tt>
* and minor if <tt>isMarkMinor() == true</tt>.
*
* @param text the text of the page
* @param title the title of the page
* @param summary the edit summary. See [[Help:Edit summary]]. Summaries
* longer than 200 characters are truncated server-side.
* @param section the section to edit. Use -1 to specify a new section and
* -2 to disable section editing.
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialException if page is protected and we can't edit it
* @throws UnsupportedOperationException if you try to edit a Special: or a
* Media: page
* @see #getPageText
* @since 0.25
*/
public void edit(String title, String text, String summary, int section) throws IOException, LoginException
{
edit(title, text, summary, markminor, markbot, section, null);
}
/**
* Edits a page by setting its text to the supplied value. This method is
* thread safe and blocks for a minimum time as specified by the
* throttle. The edit will be marked bot if <tt>isMarkBot() == true</tt>
* and minor if <tt>isMarkMinor() == true</tt>.
*
* @param text the text of the page
* @param title the title of the page
* @param summary the edit summary. See [[Help:Edit summary]]. Summaries
* longer than 200 characters are truncated server-side.
* @param section the section to edit. Use -1 to specify a new section and
* -2 to disable section editing.
* @param basetime the timestamp of the revision on which <tt>text</tt> is
* based, used to check for edit conflicts. <tt>null</tt> disables this.
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialException if page is protected and we can't edit it
* @throws UnsupportedOperationException if you try to edit a Special: or a
* Media: page
* @see #getPageText
* @since 0.25
*/
public void edit(String title, String text, String summary, int section, Calendar basetime)
throws IOException, LoginException
{
edit(title, text, summary, markminor, markbot, section, basetime);
}
/**
* Edits a page by setting its text to the supplied value. This method is
* thread safe and blocks for a minimum time as specified by the
* throttle.
*
* @param text the text of the page
* @param title the title of the page
* @param summary the edit summary or the title of the new section. See
* [[Help:Edit summary]]. Summaries longer than 200 characters are
* truncated server-side.
* @param minor whether the edit should be marked as minor, See
* [[Help:Minor edit]].
* @param bot whether to mark the edit as a bot edit (ignored if one does
* not have the necessary permissions)
* @param section the section to edit. Use -1 to specify a new section and
* -2 to disable section editing.
* @param basetime the timestamp of the revision on which <tt>text</tt> is
* based, used to check for edit conflicts. <tt>null</tt> disables this.
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialException if page is protected and we can't edit it
* @throws UnsupportedOperationException if you try to edit a Special: or
* Media: page
* @see #getPageText
* @since 0.17
*/
public synchronized void edit(String title, String text, String summary, boolean minor, boolean bot,
int section, Calendar basetime) throws IOException, LoginException
{
// @revised 0.16 to use API edit. No more screenscraping - yay!
// @revised 0.17 section editing
// @revised 0.25 optional bot flagging
long start = System.currentTimeMillis();
// protection and token
Map info = getPageInfo(title);
if (!checkRights(info, "edit") || (Boolean)info.get("exists") && !checkRights(info, "create"))
{
CredentialException ex = new CredentialException("Permission denied: page is protected.");
log(Level.WARNING, "edit", "Cannot edit - permission denied. " + ex);
throw ex;
}
String wpEditToken = (String)info.get("token");
// post data
StringBuilder buffer = new StringBuilder(300000);
buffer.append("title=");
buffer.append(URLEncoder.encode(normalize(title), "UTF-8"));
buffer.append("&text=");
buffer.append(URLEncoder.encode(text, "UTF-8"));
if (section != -1)
{
// edit summary created automatically if making a new section
buffer.append("&summary=");
buffer.append(URLEncoder.encode(summary, "UTF-8"));
}
buffer.append("&token=");
buffer.append(URLEncoder.encode(wpEditToken, "UTF-8"));
if (basetime != null)
{
buffer.append("&starttimestamp=");
buffer.append(calendarToTimestamp((Calendar)info.get("timestamp")));
buffer.append("&basetimestamp=");
// I wonder if the time getPageText() was called suffices here
buffer.append(calendarToTimestamp(basetime));
}
if (minor)
buffer.append("&minor=1");
if (bot && user.isAllowedTo("bot"))
buffer.append("&bot=1");
if (section == -1)
{
buffer.append("&section=new&sectiontitle=");
buffer.append(URLEncoder.encode(summary, "UTF-8"));
}
else if (section != -2)
{
buffer.append("&section=");
buffer.append(section);
}
String response = post(apiUrl + "action=edit", buffer.toString(), "edit");
// done
if (response.contains("error code=\"editconflict\""))
{
log(Level.WARNING, "edit", "Edit conflict on " + title);
return; // hmmm, perhaps we should throw an exception. I'm open to ideas.
}
try
{
checkErrorsAndUpdateStatus(response, "edit");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "edit", "Exception: " + e.getMessage() + " Retrying...");
edit(title, text, summary, minor, bot, section, basetime);
}
else
{
log(Level.SEVERE, "edit", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "edit", "Successfully edited " + title);
retry = true;
throttle(start);
}
/**
* Creates a new section on the specified page. Leave <tt>subject</tt> as
* the empty string if you just want to append.
*
* @param title the title of the page to edit
* @param subject the subject of the new section
* @param text the text of the new section
* @param minor whether the edit should be marked as minor (see
* [[Help:Minor edit]])
* @param bot whether to mark the edit as a bot edit (ignored if one does
* not have the necessary permissions)
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialException if page is protected and we can't edit it
* @throws CredentialExpiredException if cookies have expired
* @throws UnsupportedOperationException if you try to edit a Special: or
* Media: page
* @since 0.17
*/
public void newSection(String title, String subject, String text, boolean minor, boolean bot) throws IOException, LoginException
{
edit(title, text, subject, minor, bot, -1, null);
}
/**
* Prepends something to the given page. A convenience method for
* adding maintainance templates, rather than getting and setting the
* page yourself.
*
* @param title the title of the page
* @param stuff what to prepend to the page
* @param summary the edit summary. See [[Help:Edit summary]]. Summaries
* longer than 200 characters are truncated server-side.
* @param minor whether the edit is minor
* @param bot whether to mark the edit as a bot edit (ignored if one does
* not have the necessary permissions)
* @throws AccountLockedException if user is blocked
* @throws CredentialException if page is protected and we can't edit it
* @throws CredentialExpiredException if cookies have expired
* @throws UnsupportedOperationException if you try to retrieve the text
* of a Special: page or a Media: page
* @throws IOException if a network error occurs
*/
public void prepend(String title, String stuff, String summary, boolean minor, boolean bot) throws IOException, LoginException
{
StringBuilder text = new StringBuilder(100000);
text.append(stuff);
// section 0 to save bandwidth
text.append(getSectionText(title, 0));
edit(title, text.toString(), summary, minor, bot, 0, null);
}
/**
* Deletes a page. Does not delete any page requiring <tt>bigdelete</tt>.
* @param title the page to delete
* @param reason the reason for deletion
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if the user lacks the permission to
* delete
* @throws CredentialExpiredException if cookies have expired
* @throws AccountLockedException if user is blocked
* @since 0.24
*/
public synchronized void delete(String title, String reason) throws IOException, LoginException
{
long start = System.currentTimeMillis();
if (user == null || !user.isAllowedTo("delete"))
throw new CredentialNotFoundException("Cannot delete: Permission denied");
// edit token
Map info = getPageInfo(title);
if (!(Boolean)info.get("exists"))
{
log(Level.INFO, "delete", "Page \"" + title + "\" does not exist.");
return;
}
String deleteToken = (String)info.get("token");
// post data
StringBuilder buffer = new StringBuilder(500);
buffer.append("title=");
buffer.append(URLEncoder.encode(normalize(title), "UTF-8"));
buffer.append("&reason=");
buffer.append(URLEncoder.encode(reason, "UTF-8"));
buffer.append("&token=");
buffer.append(URLEncoder.encode(deleteToken, "UTF-8"));
String response = post(apiUrl + "action=delete", buffer.toString(), "delete");
// done
try
{
if (!response.contains("<delete title="))
checkErrorsAndUpdateStatus(response, "delete");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "delete", "Exception: " + e.getMessage() + " Retrying...");
delete(title, reason);
}
else
{
log(Level.SEVERE, "delete", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "delete", "Successfully deleted " + title);
retry = true;
throttle(start);
}
/**
* Undeletes a page. Equivalent to [[Special:Undelete]]. Restores ALL deleted
* revisions and files by default. This method is throttled.
* @param title a page to undelete
* @param reason the reason for undeletion
* @param revisions a list of revisions for selective undeletion
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot undelete
* @throws CredentialExpiredException if cookies have expired
* @throws AccountLockedException if user is blocked
* @since 0.30
*/
public synchronized void undelete(String title, String reason, Revision... revisions) throws IOException, LoginException
{
long start = System.currentTimeMillis();
if (user == null || !user.isAllowedTo("undelete"))
throw new CredentialNotFoundException("Cannot undelete: Permission denied");
// deleted revisions token
String titleenc = URLEncoder.encode(normalize(title), "UTF-8");
String delrev = query + "action=query&list=deletedrevs&drlimit=1&drprop=token&titles=" + titleenc;
if (!delrev.contains("token=\"")) // nothing to undelete
{
log(Level.WARNING, "undelete", "Page \"" + title + "\" has no deleted revisions!");
return;
}
String drtoken = parseAttribute(delrev, "token", 0);
StringBuilder out = new StringBuilder("title=");
out.append(titleenc);
out.append("&reason=");
out.append(URLEncoder.encode(reason, "UTF-8"));
out.append("&token=");
out.append(URLEncoder.encode(drtoken, "UTF-8"));
if (revisions.length != 0)
{
out.append("&timestamps=");
for (int i = 0; i < revisions.length - 1; i++)
{
out.append(calendarToTimestamp(revisions[i].getTimestamp()));
out.append("%7C");
}
out.append(calendarToTimestamp(revisions[revisions.length - 1].getTimestamp()));
}
String response = post(apiUrl + "action=undelete", out.toString(), "undelete");
// done
try
{
if (!response.contains("<undelete title="))
checkErrorsAndUpdateStatus(response, "undelete");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "undelete", "Exception: " + e.getMessage() + " Retrying...");
undelete(title, reason, revisions);
}
else
{
log(Level.SEVERE, "undelete", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "undelete", "Successfully undeleted " + title);
retry = true;
for (Revision rev : revisions)
rev.pageDeleted = false;
throttle(start);
}
/**
* Purges the server-side cache for various pages.
* @param titles the titles of the page to purge
* @param links update the links tables
* @throws IOException if a network error occurs
* @since 0.17
*/
public void purge(boolean links, String... titles) throws IOException
{
StringBuilder url = new StringBuilder(apiUrl);
url.append("action=purge");
if (links)
url.append("&forcelinkupdate");
String[] temp = constructTitleString(titles);
for (String x : temp)
post(url.toString(), "&titles=" + x, "purge");
log(Level.INFO, "purge", "Successfully purged " + titles.length + " pages.");
}
/**
* Gets the list of images used on a particular page. Capped at
* <tt>max</tt> number of images, there's no reason why there should be
* more than that.
*
* @param title a page
* @return the list of images used in the page. Note that each String in the array will begin with the
* prefix "File:"
* @throws IOException if a network error occurs
* @since 0.16
*/
public String[] getImagesOnPage(String title) throws IOException
{
String url = query + "prop=images&imlimit=max&titles=" + URLEncoder.encode(normalize(title), "UTF-8");
String line = fetch(url, "getImagesOnPage");
// xml form: <im ns="6" title="File:Example.jpg" />
List<String> images = new ArrayList<>(750);
for (int a = line.indexOf("<im "); a > 0; a = line.indexOf("<im ", ++a))
images.add(parseAttribute(line, "title", a));
int temp = images.size();
log(Level.INFO, "getImagesOnPage", "Successfully retrieved images used on " + title + " (" + temp + " images)");
return images.toArray(new String[temp]);
}
/**
* Gets the list of categories a particular page is in. Includes hidden
* categories. Capped at <tt>max</tt> number of categories, there's no
* reason why there should be more than that.
*
* @param title a page
* @return the list of categories that page is in
* @throws IOException if a network error occurs
* @since 0.16
*/
public String[] getCategories(String title) throws IOException
{
return getCategories(title, false, false);
}
/**
* Gets the list of categories a particular page is in. Ignores hidden
* categories if ignoreHidden is true. Also includes the sortkey of a
* category if sortkey is true. The sortkey would then be appended to
* the element of the returned string array (separated by "|").
* Capped at <tt>max</tt> number of categories, there's no reason why
* there should be more than that.
*
* @param title a page
* @param sortkey return a sortkey as well (default = false)
* @param ignoreHidden skip hidden categories (default = false)
* @return the list of categories that the page is in
* @throws IOException if a network error occurs
* @since 0.30
*/
public String[] getCategories(String title, boolean sortkey, boolean ignoreHidden) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=categories&cllimit=max");
if (sortkey || ignoreHidden)
url.append("&clprop=sortkey%7Chidden");
url.append("&titles=");
url.append(URLEncoder.encode(title, "UTF-8"));
String line = fetch(url.toString(), "getCategories");
// xml form: <cl ns="14" title="Category:1879 births" sortkey=(long string) sortkeyprefix="" />
// or : <cl ns="14" title="Category:Images for cleanup" sortkey=(long string) sortkeyprefix="Borders" hidden="" />
List<String> categories = new ArrayList<>(750);
int a, b; // beginIndex and endIndex
for ( a = line.indexOf("<cl "); a > 0; a = b )
{
b = line.indexOf("<cl ", a+1);
if (ignoreHidden && line.substring(a, (b > 0 ? b : line.length())).contains("hidden"))
continue;
String category = parseAttribute(line, "title", a);
if (sortkey)
category += ("|" + parseAttribute(line, "sortkeyprefix", a));
categories.add(category);
}
int temp = categories.size();
log(Level.INFO, "getCategories", "Successfully retrieved categories of " + title + " (" + temp + " categories)");
return categories.toArray(new String[temp]);
}
/**
* Gets the list of templates used on a particular page that are in a
* particular namespace(s). Capped at <tt>max</tt> number of templates,
* there's no reason why there should be more than that.
*
* @param title a page
* @param ns a list of namespaces to filter by, empty = all namespaces.
* @return the list of templates used on that page in that namespace
* @throws IOException if a network error occurs
* @since 0.16
*/
public String[] getTemplates(String title, int... ns) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=templates&tllimit=max&titles=");
url.append(URLEncoder.encode(normalize(title), "UTF-8"));
constructNamespaceString(url, "tl", ns);
String line = fetch(url.toString(), "getTemplates");
// xml form: <tl ns="10" title="Template:POTD" />
List<String> templates = new ArrayList<>(750);
for (int a = line.indexOf("<tl "); a > 0; a = line.indexOf("<tl ", ++a))
templates.add(parseAttribute(line, "title", a));
int size = templates.size();
log(Level.INFO, "getTemplates", "Successfully retrieved templates used on " + title + " (" + size + " templates)");
return templates.toArray(new String[size]);
}
/**
* Gets the list of interwiki links a particular page has. The returned
* map has the format language code => the page on the external wiki
* linked to.
*
* @param title a page
* @return a map of interwiki links that page has (empty if there are no
* links)
* @throws IOException if a network error occurs
* @since 0.18
*/
public Map<String, String> getInterWikiLinks(String title) throws IOException
{
String url = query + "prop=langlinks&lllimit=max&titles=" + URLEncoder.encode(normalize(title), "UTF-8");
String line = fetch(url, "getInterwikiLinks");
// xml form: <ll lang="en" />Main Page</ll> or <ll lang="en" /> for [[Main Page]]
Map<String, String> interwikis = new HashMap<>(750);
for (int a = line.indexOf("<ll "); a > 0; a = line.indexOf("<ll ", ++a))
{
String language = parseAttribute(line, "lang", a);
int b = line.indexOf('>', a) + 1;
int c = line.indexOf('<', b);
String page = decode(line.substring(b, c));
interwikis.put(language, page);
}
log(Level.INFO, "getInterWikiLinks", "Successfully retrieved interwiki links on " + title);
return interwikis;
}
/**
* Gets the list of wikilinks used on a particular page. Patch somewhat by
* wim.jongman
*
* @param title a page
* @return the list of links used in the page
* @throws IOException if a network error occurs
* @since 0.24
*/
public String[] getLinksOnPage(String title) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=links&pllimit=max&titles=");
url.append(URLEncoder.encode(normalize(title), "UTF-8"));
String plcontinue = null;
List<String> links = new ArrayList<>(750);
do
{
String line;
if (plcontinue == null)
line = fetch(url.toString(), "getLinksOnPage");
else
line = fetch(url.toString() + "&plcontinue=" + URLEncoder.encode(plcontinue, "UTF-8"), "getLinksOnPage");
plcontinue = parseAttribute(line, "plcontinue", 0);
// xml form: <pl ns="6" title="page name" />
for (int a = line.indexOf("<pl "); a > 0; a = line.indexOf("<pl ", ++a))
links.add(parseAttribute(line, "title", a));
}
while (plcontinue != null);
int size = links.size();
log(Level.INFO, "getLinksOnPage", "Successfully retrieved links used on " + title + " (" + size + " links)");
return links.toArray(new String[size]);
}
/**
* Gets the list of external links used on a particular page.
*
* @param title a page
* @return the list of external links used in the page
* @throws IOException if a network error occurs
* @since 0.29
*/
public String[] getExternalLinksOnPage(String title) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=extlinks&ellimit=max&titles=");
url.append(URLEncoder.encode(normalize(title), "UTF-8"));
String eloffset = null;
List<String> links = new ArrayList<>(750);
do
{
String line;
if (eloffset == null)
line = fetch(url.toString(), "getExternalLinksOnPage");
else
line = fetch(url.toString() + "&eloffset=" + URLEncoder.encode(eloffset, "UTF-8"), "getExternalLinksOnPage");
eloffset = parseAttribute(line, "eloffset", 0);
// xml form: <pl ns="6" title="page name" />
for (int a = line.indexOf("<el "); a > 0; a = line.indexOf("<el ", ++a))
{
int x = line.indexOf('>', a) + 1;
int y = line.indexOf("</el>", x);
links.add(decode(line.substring(x, y)));
}
}
while (eloffset != null);
int size = links.size();
log(Level.INFO, "getExternalLinksOnPage", "Successfully retrieved external links used on " + title + " (" + size + " links)");
return links.toArray(new String[size]);
}
/**
* Gets the list of sections on a particular page. The returned map pairs
* the section numbering as in the table of contents with the section
* title, as in the following example:
*
* 1 => How to nominate
* 1.1 => Step 1 - Evaluate
* 1.2 => Step 2 - Create subpage
* 1.2.1 => Step 2.5 - Transclude and link
* 1.3 => Step 3 - Update image
* ...
*
* @param page the page to get sections for
* @return the section map for that page
* @throws IOException if a network error occurs
* @since 0.18
*/
public LinkedHashMap<String, String> getSectionMap(String page) throws IOException
{
String url = apiUrl + "action=parse&text={{:" + URLEncoder.encode(page, "UTF-8") + "}}__TOC__&prop=sections";
String line = fetch(url, "getSectionMap");
// xml form: <s toclevel="1" level="2" line="How to nominate" number="1" />
LinkedHashMap<String, String> map = new LinkedHashMap<>(30);
for (int a = line.indexOf("<s "); a > 0; a = line.indexOf("<s ", ++a))
{
String title = parseAttribute(line, "line", a);
String number = parseAttribute(line, "number", a);
map.put(number, title);
}
log(Level.INFO, "getSectionMap", "Successfully retrieved section map for " + page);
return map;
}
/**
* Gets the most recent revision of a page, or null if the page does not exist.
* @param title a page
* @return the most recent revision of that page
* @throws IOException if a network error occurs
* @since 0.24
*/
public Revision getTopRevision(String title) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=revisions&rvlimit=1&rvtoken=rollback&titles=");
url.append(URLEncoder.encode(normalize(title), "UTF-8"));
url.append("&rvprop=timestamp%7Cuser%7Cids%7Cflags%7Csize%7Ccomment");
String line = fetch(url.toString(), "getTopRevision");
int a = line.indexOf("<rev "); // important space
int b = line.indexOf("/>", a);
if (a < 0) // page does not exist
return null;
return parseRevision(line.substring(a, b), title);
}
/**
* Gets the first revision of a page, or null if the page does not exist.
* @param title a page
* @return the oldest revision of that page
* @throws IOException if a network error occurs
* @since 0.24
*/
public Revision getFirstRevision(String title) throws IOException
{
StringBuilder url = new StringBuilder(query);
url.append("prop=revisions&rvlimit=1&rvdir=newer&titles=");
url.append(URLEncoder.encode(normalize(title), "UTF-8"));
url.append("&rvprop=timestamp%7Cuser%7Cids%7Cflags%7Csize%7Ccomment");
String line = fetch(url.toString(), "getFirstRevision");
int a = line.indexOf("<rev "); // important space!
int b = line.indexOf("/>", a);
if (a < 0) // page does not exist
return null;
return parseRevision(line.substring(a, b), title);
}
/**
* Gets the newest page name or the name of a page where the asked page
* redirects.
* @param title a title
* @return the page redirected to or null if not a redirect
* @throws IOException if a network error occurs
* @since 0.29
*/
public String resolveRedirect(String title) throws IOException
{
return resolveRedirects(new String[] { title })[0];
}
/**
* Gets the newest page name or the name of a page where the asked pages
* redirect.
* @param titles a list of titles.
* @return for each title, the page redirected to or null if not a redirect
* @throws IOException if a network error occurs
* @since 0.29
* @author Nirvanchik/MER-C
*/
public String[] resolveRedirects(String[] titles) throws IOException
{
StringBuilder url = new StringBuilder(query);
if (!resolveredirect)
url.append("redirects&");
url.append("titles=");
String[] ret = new String[titles.length];
String[] temp = constructTitleString(titles);
for (String blah : temp)
{
String line = fetch(url.toString() + blah, "resolveRedirects");
// expected form: <redirects><r from="Main page" to="Main Page"/>
// <r from="Home Page" to="Home page"/>...</redirects>
// TODO: look for the <r> tag instead
for (int j = line.indexOf("<r "); j > 0; j = line.indexOf("<r ", ++j))
{
String parsedtitle = parseAttribute(line, "from", j);
for (int i = 0; i < titles.length; i++)
if (normalize(titles[i]).equals(parsedtitle))
ret[i] = parseAttribute(line, "to", j);
}
}
return ret;
}
/**
* Gets the entire revision history of a page. Be careful when using
* this method as some pages (such as [[Wikipedia:Administrators'
* noticeboard/Incidents]] have ~10^6 revisions.
*
* @param title a page
* @return the revisions of that page
* @throws IOException if a network error occurs
* @since 0.19
*/
public Revision[] getPageHistory(String title) throws IOException
{
return getPageHistory(title, null, null, false);
}
/**
* Gets the revision history of a page between two dates.
* @param title a page
* @param start the EARLIEST of the two dates
* @param end the LATEST of the two dates
* @param reverse whether to put the oldest first (default = false, newest
* first is how history pages work)
* @return the revisions of that page in that time span
* @throws IOException if a network error occurs
* @since 0.19
*/
public Revision[] getPageHistory(String title, Calendar start, Calendar end, boolean reverse) throws IOException
{
// set up the url
StringBuilder url = new StringBuilder(query);
url.append("prop=revisions&rvlimit=max&titles=");
url.append(URLEncoder.encode(normalize(title), "UTF-8"));
url.append("&rvprop=timestamp%7Cuser%7Cids%7Cflags%7Csize%7Ccomment");
if (reverse)
url.append("&rvdir=newer");
if (start != null)
{
url.append(reverse ? "&rvstart=" : "&rvend=");
url.append(calendarToTimestamp(start));
}
if (end != null)
{
url.append(reverse ? "&rvend=" : "&rvstart=");
url.append(calendarToTimestamp(end));
}
String rvcontinue = null;
List<Revision> revisions = new ArrayList<>(1500);
// main loop
do
{
String line;
if (rvcontinue == null)
line = fetch(url.toString(), "getPageHistory");
else
line = fetch(url.toString() + "&rvcontinue=" + rvcontinue, "getPageHistory");
rvcontinue = parseAttribute(line, "rvcontinue", 0);
// parse stuff
for (int a = line.indexOf("<rev "); a > 0; a = line.indexOf("<rev ", ++a))
{
int b = line.indexOf("/>", a);
revisions.add(parseRevision(line.substring(a, b), title));
}
}
while (rvcontinue != null);
// populate previous/next
int size = revisions.size();
Revision[] temp = revisions.toArray(new Revision[size]);
for (int i = 0; i < size; i++)
{
if (i != 0)
temp[i].next = temp[i - 1].revid;
if (i != size - 1)
temp[i].sizediff = temp[i].size - temp[i + 1].size;
else
temp[i].sizediff = temp[i].size;
}
log(Level.INFO, "getPageHistory", "Successfully retrieved page history of " + title + " (" + size + " revisions)");
return temp;
}
/**
* Gets the deleted history of a page.
* @param title a page
* @return the deleted revisions of that page in that time span
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot obtain deleted revisions
* @since 0.30
*/
public Revision[] getDeletedHistory(String title) throws IOException, CredentialNotFoundException
{
return getDeletedHistory(title, null, null, false);
}
/**
* Gets the deleted history of a page.
* @param title a page
* @param start the EARLIEST of the two dates
* @param end the LATEST of the two dates
* @param reverse whether to put the oldest first (default = false, newest
* first is how history pages work)
* @return the deleted revisions of that page in that time span
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot obtain deleted revisions
* @since 0.30
*/
public Revision[] getDeletedHistory(String title, Calendar start, Calendar end, boolean reverse)
throws IOException, CredentialNotFoundException
{
// admin queries are annoying
if (!user.isAllowedTo("deletedhistory"))
throw new CredentialNotFoundException("Permission denied: not able to view deleted history");
StringBuilder url = new StringBuilder(query);
url.append("prop=deletedrevisions&drvprop=ids%7Cuser%7Cflags%7Csize%7Ccomment&drvlimit=max");
if (reverse)
url.append("&drvdir=newer");
if (start != null)
{
url.append(reverse ? "&drvstart=" : "&drvend=");
url.append(calendarToTimestamp(start));
}
if (end != null)
{
url.append(reverse ? "&drvend=" : "&drvstart=");
url.append(calendarToTimestamp(end));
}
url.append("&titles=");
url.append(URLEncoder.encode(title, "UTF-8"));
String drvcontinue = null;
List<Revision> delrevs = new ArrayList<>(500);
do
{
String response;
if (drvcontinue != null)
response = fetch(url.toString() + "&drvcontinue=" + URLEncoder.encode(drvcontinue, "UTF-8"), "getDeletedHistory");
else
response = fetch(url.toString(), "getDeletedHistory");
drvcontinue = parseAttribute(response, "drvcontinue", 0);
// parse
int x = response.indexOf("<deletedrevs>");
if (x < 0) // no deleted history
break;
for (x = response.indexOf("<page ", x); x > 0; x = response.indexOf("<page ", ++x))
{
String deltitle = parseAttribute(response, "title", x);
int y = response.indexOf("</page>", x);
for (int z = response.indexOf("<rev ", x); z < y && z >= 0; z = response.indexOf("<rev ", ++z))
{
int aa = response.indexOf(" />", z);
Revision temp = parseRevision(response.substring(z, aa), deltitle);
temp.pageDeleted = true;
delrevs.add(temp);
}
}
}
while (drvcontinue != null);
int size = delrevs.size();
log(Level.INFO, "Successfully fetched " + size + " deleted revisions.", "deletedRevs");
return delrevs.toArray(new Revision[size]);
}
/**
* Gets the deleted contributions of a user. Equivalent to
* [[Special:Deletedcontributions]].
* @param u a user
* @return the deleted contributions of that user
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot obtain deleted revisions
* @since 0.30
*/
public Revision[] deletedContribs(String u) throws IOException, CredentialNotFoundException
{
return deletedContribs(u, null, null, false, ALL_NAMESPACES);
}
/**
* Gets the deleted contributions of a user in the given namespace. Equivalent to
* [[Special:Deletedcontributions]].
* @param u a user
* @param start the EARLIEST of the two dates
* @param end the LATEST of the two dates
* @param reverse whether to put the oldest first (default = false, newest
* first is how history pages work)
* @param namespace a list of namespaces
* @return the deleted contributions of that user
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot obtain deleted revisions
* @since 0.30
*/
public Revision[] deletedContribs(String u, Calendar end, Calendar start, boolean reverse, int... namespace)
throws IOException, CredentialNotFoundException
{
// admin queries are annoying
if (!user.isAllowedTo("deletedhistory"))
throw new CredentialNotFoundException("Permission denied: not able to view deleted history");
StringBuilder url = new StringBuilder(query);
url.append("list=alldeletedrevisions&adrprop=ids%7Cuser%7Cflags%7Csize%7Ccomment&adrlimit=max");
if (reverse)
url.append("&adrdir=newer");
if (start != null)
{
url.append(reverse ? "&adrstart=" : "&adrend=");
url.append(calendarToTimestamp(start));
}
if (end != null)
{
url.append(reverse ? "&adrend=" : "&adrstart=");
url.append(calendarToTimestamp(end));
}
url.append("&adruser=");
url.append(URLEncoder.encode(u, "UTF-8"));
constructNamespaceString(url, "adr", namespace);
String adrcontinue = null;
List<Revision> delrevs = new ArrayList<>(500);
do
{
String response;
if (adrcontinue != null)
response = fetch(url.toString() + "&adrcontinue=" + URLEncoder.encode(adrcontinue, "UTF-8"), "deletedContribs");
else
response = fetch(url.toString(), "deletedContribs");
adrcontinue = parseAttribute(response, "adrcontinue", 0);
// parse
int x = response.indexOf("<deletedrevs>");
if (x < 0) // no deleted history
break;
for (x = response.indexOf("<page ", x); x > 0; x = response.indexOf("<page ", ++x))
{
String deltitle = parseAttribute(response, "title", x);
int y = response.indexOf("</page>", x);
for (int z = response.indexOf("<rev ", x); z < y && z >= 0; z = response.indexOf("<rev ", ++z))
{
int aa = response.indexOf(" />", z);
Revision temp = parseRevision(response.substring(z, aa), deltitle);
temp.pageDeleted = true;
delrevs.add(temp);
}
}
}
while (adrcontinue != null);
int size = delrevs.size();
log(Level.INFO, "Successfully fetched " + size + " deleted revisions.", "deletedRevs");
return delrevs.toArray(new Revision[size]);
}
/**
* Returns all deleted pages that begin with the given prefix. WARNING:
* this does not behave like [[Special:Prefixindex]]. See [[Special:Undelete]]
* with no arguments.
*
* @param prefix a prefix without a namespace specifier, empty string
* lists all deleted pages in the namespace.
* @param namespace one (and only one) namespace
* @return (see above)
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot view deleted pages
* @since 0.31
*/
public String[] deletedPrefixIndex(String prefix, int namespace) throws IOException, CredentialNotFoundException
{
// this is currently BROKEN
if (!user.isAllowedTo("deletedhistory") || !user.isAllowedTo("deletedtext"))
throw new CredentialNotFoundException("Permission denied: not able to view deleted history or text.");
StringBuilder url = new StringBuilder(query);
// drdir also reverses sort order for some reason
url.append("list=deletedrevs&drlimit=max&drunique=1&drdir=newer&drprefix=");
url.append(URLEncoder.encode(prefix, "UTF-8"));
url.append("&drnamespace=");
url.append(namespace);
String drcontinue = null;
List<String> pages = new ArrayList<>();
do
{
String text;
if (drcontinue == null)
text = fetch(url.toString(), "deletedPrefixIndex");
else
text = fetch(url.toString() + "&drcontinue=" + URLEncoder.encode(drcontinue, "UTF-8"), "deletedPrefixIndex");
drcontinue = parseAttribute(text, drcontinue, 0);
for (int x = text.indexOf("<page ", 0); x > 0; x = text.indexOf("<page ", ++x))
pages.add(parseAttribute(text, "title", x));
}
while (drcontinue != null);
int size = pages.size();
log(Level.INFO, "deletedPrefixIndex", "Successfully retrieved deleted page list (" + size + " items).");
return pages.toArray(new String[size]);
}
/**
* Gets the text of a deleted page (it's like getPageText, but for deleted
* pages).
* @param page a page
* @return the deleted text
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if we cannot obtain deleted revisions
* @since 0.30
*/
public String getDeletedText(String page) throws IOException, CredentialNotFoundException
{
if (!user.isAllowedTo("deletedhistory") || !user.isAllowedTo("deletedtext"))
throw new CredentialNotFoundException("Permission denied: not able to view deleted history or text.");
// TODO: this can be multiquery(?)
StringBuilder url = new StringBuilder(query);
url.append("list=deletedrevs&drlimit=1&drprop=content&titles=");
url.append(URLEncoder.encode(page, "UTF-8"));
// expected form: <rev timestamp="2009-04-05T22:40:35Z" xml:space="preserve">TEXT OF PAGE</rev>
String line = fetch(url.toString(), "getDeletedText");
int a = line.indexOf("<rev ");
a = line.indexOf(">", a) + 1;
int b = line.indexOf("</rev>", a);
return line.substring(a, b);
}
/**
* Moves a page. Moves the associated talk page and leaves redirects, if
* applicable. Equivalent to [[Special:MovePage]]. This method is thread
* safe and is subject to the throttle.
*
* @param title the title of the page to move
* @param newTitle the new title of the page
* @param reason a reason for the move
* @throws UnsupportedOperationException if the original page is in the
* Category or Image namespace. MediaWiki does not support moving of
* these pages.
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if not logged in
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialException if page is protected and we can't move it
* @since 0.16
*/
public void move(String title, String newTitle, String reason) throws IOException, LoginException
{
move(title, newTitle, reason, false, true, false);
}
/**
* Moves a page. Equivalent to [[Special:MovePage]]. This method is
* thread safe and is subject to the throttle.
*
* @param title the title of the page to move
* @param newTitle the new title of the page
* @param reason a reason for the move
* @param noredirect don't leave a redirect behind. You need to be a
* admin to do this, otherwise this option is ignored.
* @param movesubpages move the subpages of this page as well. You need to
* be an admin to do this, otherwise this will be ignored.
* @param movetalk move the talk page as well (if applicable)
* @throws UnsupportedOperationException if the original page is in the
* Category namespace. MediaWiki does not support moving of these pages.
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if not logged in
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialException if page is protected and we can't move it
* @since 0.16
*/
public synchronized void move(String title, String newTitle, String reason, boolean noredirect, boolean movetalk,
boolean movesubpages) throws IOException, LoginException
{
long start = System.currentTimeMillis();
// check for log in
if (user == null || !user.isAllowedTo("move"))
{
CredentialNotFoundException ex = new CredentialNotFoundException("Permission denied: cannot move pages.");
log(Level.SEVERE, "move", "Cannot move - permission denied: " + ex);
throw ex;
}
// check namespace
if (namespace(title) == CATEGORY_NAMESPACE)
throw new UnsupportedOperationException("Tried to move a category.");
// protection and token
Map info = getPageInfo(title);
// determine whether the page exists
if (!(Boolean)info.get("exists"))
throw new IllegalArgumentException("Tried to move a non-existant page!");
if (!checkRights(info, "move"))
{
CredentialException ex = new CredentialException("Permission denied: page is protected.");
log(Level.WARNING, "move", "Cannot move - permission denied. " + ex);
throw ex;
}
String wpMoveToken = (String)info.get("token");
// post data
StringBuilder buffer = new StringBuilder(10000);
buffer.append("from=");
buffer.append(URLEncoder.encode(title, "UTF-8"));
buffer.append("&to=");
buffer.append(URLEncoder.encode(newTitle, "UTF-8"));
buffer.append("&reason=");
buffer.append(URLEncoder.encode(reason, "UTF-8"));
buffer.append("&token=");
buffer.append(URLEncoder.encode(wpMoveToken, "UTF-8"));
if (movetalk)
buffer.append("&movetalk=1");
if (noredirect && user.isAllowedTo("suppressredirect"))
buffer.append("&noredirect=1");
if (movesubpages && user.isAllowedTo("move-subpages"))
buffer.append("&movesubpages=1");
String response = post(apiUrl + "action=move", buffer.toString(), "move");
// done
try
{
// success
if (!response.contains("move from"))
checkErrorsAndUpdateStatus(response, "move");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "move", "Exception: " + e.getMessage() + " Retrying...");
move(title, newTitle, reason, noredirect, movetalk, movesubpages);
}
else
{
log(Level.SEVERE, "move", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "move", "Successfully moved " + title + " to " + newTitle);
retry = true;
throttle(start);
}
/**
* Protects a page. Structure of <tt>protectionstate</tt> (everything is
* optional, if a value is not present, then the corresponding values will
* be left untouched):
* <pre>
* {
* edit => one of { NO_PROTECTION, SEMI_PROTECTION, FULL_PROTECTION }, // restricts editing
* editexpiry => Calendar, // expiry time for edit protection, null = indefinite
* move, moveexpiry, // as above, prevents page moving
* create, createexpiry, // as above, prevents page creation (no effect on existing pages)
* upload, uploadexpiry, // as above, prevents uploading of files (FILE_NAMESPACE only)
* cascade => Boolean // Enables cascading protection (requires edit=FULL_PROTECTION). Default: false.
* cascadesource => String // souce of cascading protection (here ignored)
* };
* </pre>
*
* @param page the page
* @param protectionstate (see above)
* @param reason the reason for (un)protection
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialNotFoundException if we cannot protect
* @since 0.30
*/
public synchronized void protect(String page, Map<String, Object> protectionstate, String reason) throws IOException, LoginException
{
if (user == null || !user.isAllowedTo("protect"))
throw new CredentialNotFoundException("Cannot protect: permission denied.");
long start = System.currentTimeMillis();
Map info = getPageInfo(page);
String protectToken = (String)info.get("token");
StringBuilder out = new StringBuilder("title=");
out.append(URLEncoder.encode(page, "UTF-8"));
out.append("&reason=");
out.append(URLEncoder.encode(reason, "UTF-8"));
out.append("&token=");
out.append(URLEncoder.encode(protectToken, "UTF-8"));
// cascade protection
if (protectionstate.containsKey("cascade"))
out.append("&cascade=1");
// protection levels
out.append("&protections=");
StringBuilder temp = new StringBuilder();
for (Map.Entry<String, Object> entry : protectionstate.entrySet())
{
String key = entry.getKey();
if (!key.contains("expiry") && !key.equals("cascade"))
{
out.append(key);
out.append("=");
out.append(entry.getValue());
Calendar expiry = (Calendar)protectionstate.get(key + "expiry");
temp.append(expiry == null ? "never" : calendarToTimestamp(expiry));
out.append("%7C");
temp.append("%7C");
}
}
out.delete(out.length() - 3, out.length());
temp.delete(temp.length() - 3, temp.length());
out.append("&expiry=");
out.append(temp);
System.out.println(out);
String response = post(apiUrl + "action=protect", out.toString(), "protect");
try
{
if (!response.contains("<protect "))
checkErrorsAndUpdateStatus(response, "protect");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "protect", "Exception: " + e.getMessage() + " Retrying...");
protect(page, protectionstate, reason);
}
else
{
log(Level.SEVERE, "protect", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "edit", "Successfully protected " + page);
retry = true;
throttle(start);
}
/**
* Completely unprotects a page.
* @param page the page to unprotect
* @param reason the reason for unprotection
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialNotFoundException if we cannot protect
* @since 0.30
*/
public void unprotect(String page, String reason) throws IOException, LoginException
{
Map<String, Object> state = new HashMap<>();
state.put("edit", NO_PROTECTION);
state.put("move", NO_PROTECTION);
if (namespace(page) == FILE_NAMESPACE)
state.put("upload", NO_PROTECTION);
state.put("create", NO_PROTECTION);
protect(page, state, reason);
}
/**
* Exports the current revision of this page. Equivalent to
* [[Special:Export]].
* @param title the title of the page to export
* @return the exported text
* @throws IOException if a network error occurs
* @since 0.20
*/
public String export(String title) throws IOException
{
return fetch(query + "export&exportnowrap&titles=" + URLEncoder.encode(normalize(title), "UTF-8"), "export");
}
// REVISION METHODS
/**
* Gets a revision based on a given oldid. Automatically fills out all
* attributes of that revision except <tt>rcid</tt> and <tt>rollbacktoken</tt>.
*
* @param oldid an oldid
* @return the revision corresponding to that oldid, or null if it has been
* deleted
* @throws IOException if a network error occurs
* @since 0.17
*/
public Revision getRevision(long oldid) throws IOException
{
return getRevisions( new long[] { oldid })[0];
}
/**
* Gets revisions based on given oldids. Automatically fills out all
* attributes of those revisions except <tt>rcid</tt> and <tt>rollbacktoken</tt>.
*
* @param oldids a list of oldids
* @return the revisions corresponding to those oldids, in the order of the
* input array. If a particular revision has been deleted, the corresponding
* index is null.
* @throws IOException if a network error occurs
* @since 0.29
*/
public Revision[] getRevisions(long[] oldids) throws IOException
{
// build url and connect
StringBuilder url = new StringBuilder(query);
url.append("prop=revisions&rvprop=ids%7Ctimestamp%7Cuser%7Ccomment%7Cflags%7Csize&revids=");
// chunkify oldids
String[] chunks = new String[oldids.length / slowmax + 1];
StringBuilder buffer = new StringBuilder();
for (int i = 0; i < oldids.length; i++)
{
buffer.append(oldids[i]);
if (i == oldids.length - 1 || i == slowmax - 1)
{
chunks[i / slowmax] = buffer.toString();
buffer = new StringBuilder();
}
else
buffer.append("%7C");
}
Revision[] revisions = new Revision[oldids.length];
// retch and parse
for (String chunk : chunks)
{
String line = fetch(url.toString() + chunk, "getRevision");
for (int i = line.indexOf("<page "); i > 0; i = line.indexOf("<page ", ++i))
{
int z = line.indexOf("</page>", i);
String title = parseAttribute(line, "title", i);
for (int j = line.indexOf("<rev ", i); j > 0 && j < z; j = line.indexOf("<rev ", ++j))
{
int y = line.indexOf("/>", j);
String blah = line.substring(j, y);
Revision rev = parseRevision(blah, title);
long oldid = rev.getRevid();
for (int k = 0; k < oldids.length; k++)
if (oldids[k] == oldid)
revisions[k] = rev;
}
}
}
return revisions;
}
/**
* Reverts a series of edits on the same page by the same user quickly
* provided that they are the most recent revisions on that page. If this
* is not the case, then this method does nothing. See
* [[mw:Manual:Parameters to index.php#Actions]] (look under rollback)
* for more information. The edit and reverted edits will be marked as bot
* if <tt>isMarkBot() == true</tt>.
*
* @param revision the revision to revert. <tt>revision.isTop()</tt> must
* be true for the rollback to succeed
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException if the user is not an admin
* @throws CredentialExpiredException if cookies have expired
* @throws AccountLockedException if the user is blocked
* @since 0.19
*/
public void rollback(Revision revision) throws IOException, LoginException
{
rollback(revision, markbot, "");
}
/**
* Reverts a series of edits on the same page by the same user quickly
* provided that they are the most recent revisions on that page. If this
* is not the case, then this method does nothing. See
* [[mw:Manual:Parameters to index.php#Actions]] (look under rollback)
* for more information.
*
* @param revision the revision to revert. <tt>revision.isTop()</tt> must
* be true for the rollback to succeed
* @param bot whether to mark this edit and the reverted revisions as
* bot edits (ignored if we cannot do this)
* @param reason (optional) a reason for the rollback. Use "" for the
* default ([[MediaWiki:Revertpage]]).
* @throws IOException if a network error occurs
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialNotFoundException if the user cannot rollback
* @throws AccountLockedException if the user is blocked
* @since 0.19
*/
public synchronized void rollback(Revision revision, boolean bot, String reason) throws IOException, LoginException
{
// check rights
if (user == null || !user.isAllowedTo("rollback"))
throw new CredentialNotFoundException("Permission denied: cannot rollback.");
// check whether we are "on top".
Revision top = getTopRevision(revision.getPage());
if (!top.equals(revision))
{
log(Level.INFO, "rollback", "Rollback failed: revision is not the most recent");
return;
}
// get the rollback token
String token = URLEncoder.encode(top.getRollbackToken(), "UTF-8");
// Perform the rollback. Although it's easier through the human interface, we want
// to make sense of any resulting errors.
StringBuilder buffer = new StringBuilder(10000);
buffer.append("title=");
buffer.append(URLEncoder.encode(normalize(revision.getPage()), "UTF-8"));
buffer.append("&user=");
buffer.append(URLEncoder.encode(normalize(revision.getUser()), "UTF-8"));
buffer.append("&token=");
buffer.append(token);
if (bot && user.isAllowedTo("markbotedits"))
buffer.append("&markbot=1");
if (!reason.isEmpty())
{
buffer.append("&summary=");
buffer.append(reason);
}
String response = post(apiUrl + "action=rollback", buffer.toString(), "rollback");
// done
try
{
// ignorable errors
if (response.contains("alreadyrolled"))
log(Level.INFO, "rollback", "Edit has already been rolled back.");
else if (response.contains("onlyauthor"))
log(Level.INFO, "rollback", "Cannot rollback as the page only has one author.");
// probably not ignorable (otherwise success)
else if (!response.contains("rollback title="))
checkErrorsAndUpdateStatus(response, "rollback");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "rollback", "Exception: " + e.getMessage() + " Retrying...");
rollback(revision, bot, reason);
}
else
{
log(Level.SEVERE, "rollback", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "rollback", "Successfully reverted edits by " + user + " on " + revision.getPage());
retry = true;
}
/**
* Deletes and undeletes revisions.
*
* @param hidecontent hide the content of the revision (true/false
* = hide/unhide, null = status quo)
* @param hidereason hide the edit summary or the reason for an action
* @param hideuser hide who made the revision/action
* @param reason the reason why the (un)deletion was performed
* @param suppress [[Wikipedia:Oversight]] the information in question
* (ignored if we cannot <tt>suppressrevision</tt>, null = status quo).
* @param revisions the list of revisions to (un)delete
* @throws IOException if a network error occurs
* @throws CredentialNotFoundException
* @throws AccountLockedException if the user is blocked
*/
public synchronized void revisionDelete(Boolean hidecontent, Boolean hideuser, Boolean hidereason, String reason, Boolean suppress,
Revision[] revisions) throws IOException, LoginException
{
long start = System.currentTimeMillis();
if (user == null || !user.isAllowedTo("deleterevision") || !user.isAllowedTo("deletelogentry"))
throw new CredentialNotFoundException("Permission denied: cannot revision delete.");
String deltoken = (String)getPageInfo(revisions[0].getPage()).get("token");
StringBuilder out = new StringBuilder("reason=");
out.append(URLEncoder.encode(reason, "UTF-8"));
out.append("&type=revision"); // FIXME: allow log entry deletion
out.append("&ids=");
for (int i = 0; i < revisions.length - 1; i++) // FIXME: allow more than slowmax revisions
{
out.append(revisions[i].getRevid());
out.append("%7C");
}
out.append(revisions[revisions.length - 1].getRevid());
out.append("&token=");
out.append(URLEncoder.encode(deltoken, "UTF-8"));
if (user.isAllowedTo("suppressrevision") && suppress != null)
{
if (suppress)
out.append("&suppress=yes");
else
out.append("&suppress=no");
}
// this is really stupid... I'm open to suggestions
out.append("&hide=");
StringBuilder temp = new StringBuilder("&show=");
if (hidecontent == Boolean.TRUE)
out.append("content%7C");
else if (hidecontent == Boolean.FALSE)
temp.append("content%7C");
if (hideuser == Boolean.TRUE)
out.append("user%7C");
else if (hideuser == Boolean.FALSE)
temp.append("user%7C");
if (hidereason == Boolean.TRUE)
out.append("comment");
else if (hidereason == Boolean.FALSE)
temp.append("comment");
if (out.lastIndexOf("%7C") == out.length() - 2)
out.delete(out.length() - 2, out.length());
if (temp.lastIndexOf("%7C") == temp.length() - 2)
temp.delete(temp.length() - 2, temp.length());
out.append(temp);
// send/read response
String response = post(apiUrl + "action=revisiondelete", out.toString(), "revisionDelete");
try
{
// success
if (!response.contains("<revisiondelete "))
checkErrorsAndUpdateStatus(response, "move");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "revisionDelete", "Exception: " + e.getMessage() + " Retrying...");
revisionDelete(hidecontent, hideuser, hidereason, reason, suppress, revisions);
}
else
{
log(Level.SEVERE, "revisionDelete", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
log(Level.INFO, "revisionDelete", "Successfully (un)deleted " + revisions.length + " revisions.");
retry = true;
for (Revision rev : revisions)
{
if (hideuser != null)
rev.userDeleted = hideuser;
if (hidereason != null)
rev.summaryDeleted = hidereason;
}
throttle(start);
}
/**
* Undoes revisions, equivalent to the "undo" button in the GUI page
* history. A quick explanation on how this might work - suppose the edit
* history was as follows:
*
* <ul>
* <li> (revid=541) 2009-01-13 00:01 92.45.43.227
* <li> (revid=325) 2008-12-10 11:34 Example user
* <li> (revid=314) 2008-12-10 10:15 127.0.0.1
* <li> (revid=236) 2008-08-08 08:00 Anonymous
* <li> (revid=200) 2008-07-31 16:46 EvilCabalMember
* </ul>
* Then:
* <pre>
* wiki.undo(wiki.getRevision(314L), null, reason, false); // undo revision 314 only
* wiki.undo(wiki.getRevision(236L), wiki.getRevision(325L), reason, false); // undo revisions 236-325
* </pre>
*
* This will only work if revision 541 or any subsequent edits do not
* clash with the change resulting from the undo.
*
* @param rev a revision to undo
* @param to the most recent in a range of revisions to undo. Set to null
* to undo only one revision.
* @param reason an edit summary (optional). Use "" to get the default
* [[MediaWiki:Undo-summary]].
* @param minor whether this is a minor edit
* @param bot whether this is a bot edit
* @throws IOException if a network error occurs
* @throws AccountLockedException if user is blocked
* @throws CredentialExpiredException if cookies have expired
* @throws CredentialException if page is protected and we can't edit it
* @throws IllegalArgumentException if the revisions are not on the same
* page.
* @since 0.20
*/
public synchronized void undo(Revision rev, Revision to, String reason, boolean minor,
boolean bot) throws IOException, LoginException
{
// throttle
long start = System.currentTimeMillis();
// check here to see whether the titles correspond
if (to != null && !rev.getPage().equals(to.getPage()))
throw new IllegalArgumentException("Cannot undo - the revisions supplied are not on the same page!");
// protection and token
Map info = getPageInfo(rev.getPage());
if (!checkRights(info, "edit"))
{
CredentialException ex = new CredentialException("Permission denied: page is protected.");
log(Level.WARNING, "undo", "Cannot edit - permission denied." + ex);
throw ex;
}
String wpEditToken = (String)info.get("token");
// send data
StringBuilder buffer = new StringBuilder(10000);
buffer.append("title=");
buffer.append(rev.getPage());
if (!reason.isEmpty())
{
buffer.append("&summary=");
buffer.append(reason);
}
buffer.append("&undo=");
buffer.append(rev.getRevid());
if (to != null)
{
buffer.append("&undoafter=");
buffer.append(to.getRevid());
}
if (minor)
buffer.append("&minor=1");
if (bot)
buffer.append("&bot=1");
buffer.append("&token=");
buffer.append(URLEncoder.encode(wpEditToken, "UTF-8"));
String response = post(apiUrl + "action=edit", buffer.toString(), "undo");
// done
try
{
checkErrorsAndUpdateStatus(response, "undo");
}
catch (IOException e)
{
// retry once
if (retry)
{
retry = false;
log(Level.WARNING, "undo", "Exception: " + e.getMessage() + " Retrying...");
undo(rev, to, reason, minor, bot);
}
else
{
log(Level.SEVERE, "undo", "EXCEPTION: " + e);
throw e;
}
}
if (retry)
{
String log = "Successfully undid revision(s) " + rev.getRevid();
if (to != null)
log += (" - " + to.getRevid());
log(Level.INFO, "undo", log);
}
retry = true;
throttle(start);
}
/**
* Parses stuff of the form <tt>title="L. Sprague de Camp"
* timestamp="2006-08-28T23:48:08Z" minor="" comment="robot Modifying:
* [[bg:Blah]]"</tt> into useful revision objects. Used by
* <tt>contribs()</tt>, <tt>watchlist()</tt>, <tt>getPageHistory()</tt>
* <tt>rangeContribs()</tt> and <tt>recentChanges()</tt>. NOTE: if
* RevisionDelete was used on a revision, the relevant values will be null.
*
* @param xml the XML to parse
* @param title an optional title parameter if we already know what it is
* (use "" if we don't)
* @return the Revision encoded in the XML
* @since 0.17
*/
protected Revision parseRevision(String xml, String title)
{
long oldid = Long.parseLong(parseAttribute(xml, " revid", 0));
Calendar timestamp = timestampToCalendar(parseAttribute(xml, "timestamp", 0), true);
// title
if (title.isEmpty())
title = parseAttribute(xml, "title", 0);
// summary
String summary = null;
if (xml.contains("comment=\""))
summary = parseAttribute(xml, "comment", 0);
// user
String user2 = null;
if (xml.contains("user=\""))
user2 = parseAttribute(xml, "user", 0);
// flags: minor, bot, new
boolean minor = xml.contains("minor=\"\"");
boolean bot = xml.contains("bot=\"\"");
boolean rvnew = xml.contains("new=\"\"");
// size
int size = 0;
if (xml.contains("newlen=")) // recentchanges
size = Integer.parseInt(parseAttribute(xml, "newlen", 0));
else if (xml.contains("size=\""))
size = Integer.parseInt(parseAttribute(xml, "size", 0));
else if (xml.contains("len=\"")) // deletedrevs
size = Integer.parseInt(parseAttribute(xml, "len", 0));
Revision revision = new Revision(oldid, timestamp, title, summary, user2, minor, bot, rvnew, size);
// set rcid
if (xml.contains("rcid=\""))
revision.setRcid(Long.parseLong(parseAttribute(xml, "rcid", 0)));
// rollback token; will automatically be null if we cannot rollback
if (xml.contains("rollbacktoken=\""))
revision.setRollbackToken(parseAttribute(xml, "rollbacktoken", 0));
// previous revision
if (xml.contains("parentid")) // page history/getRevision
revision.previous = Long.parseLong(parseAttribute(xml, "parentid", 0));
else if (xml.contains("old_revid")) // watchlist
revision.previous = Long.parseLong(parseAttribute(xml, "old_revid", 0));
// sizediff
if (xml.contains("oldlen=\"")) // recentchanges
revision.sizediff = revision.size - Integer.parseInt(parseAttribute(xml, "oldlen", 0));
else if (xml.contains("sizediff=\""))
revision.sizediff = Integer.parseInt(parseAttribute(xml, "sizediff", 0));
// revisiondelete
revision.summaryDeleted = xml.contains("commenthidden=\"");
revision.userDeleted = xml.contains("userhidden=\"");
return revision;
}
// IMAGE METHODS
/**
* Fetches an image file and returns the image data in a <tt>byte[]</tt>.
* Works for external repositories.
*
* @param title the title of the image (may contain "File")
* @return the image data or null if the image doesn't exist
* @deprecated expects a file as additional parameter
* @throws IOException if a network error occurs
* @since 0.10
*/
@Deprecated
public byte[] getImage(String title) throws IOException
{
return getImage(title, -1, -1);
}
/**
* Fetches an image and saves it in the given file. Warning: This does overwrite any file content!
* Works for external repositories.
*
* @param title the title of the image (may contain "File")
* @param file the file to save the image to.
* @return true or false if the image doesn't exist
* @throws FileNotFoundException if the file is a directory, cannot be created or opened
* @throws IOException if a network error occurs
* @since 0.30
*/
public boolean getImage(String title, File file) throws FileNotFoundException, IOException
{
return getImage(title, -1, -1, file);
}
/**
* Fetches a thumbnail of an image file and returns the image data
* in a <tt>byte[]</tt>. Works for external repositories.
*
* @param title the title of the image (may contain "File")
* @param width the width of the thumbnail (use -1 for actual width)
* @param height the height of the thumbnail (use -1 for actual height)
* @return the image data or null if the image doesn't exist
* @throws IOException if a network error occurs
* @deprecated expects a file as additional parameter
* @since 0.13
*/
@Deprecated
public byte[] getImage(String title, int width, int height) throws IOException
{
// this is a two step process - first we fetch the image url
title = title.replaceFirst("^(File|Image|" + namespaceIdentifier(FILE_NAMESPACE) + "):", "");
StringBuilder url = new StringBuilder(query);
url.append("prop=imageinfo&iiprop=url&titles=");
url.append(URLEncoder.encode(normalize("File:" + title), "UTF-8"));
url.append("&iiurlwidth=");
url.append(width);
url.append("&iiurlheight=");
url.append(height);