From 39e4b6a3d9dbd5a252be6dccdf0f52e240d0261d Mon Sep 17 00:00:00 2001 From: Albin Larsson Date: Sat, 9 Oct 2021 12:18:47 +0200 Subject: [PATCH] Revert "Update wiki-java #138" --- src/org/wikipedia/Wiki.java | 8002 +++++++++++++++-------------------- src/pattypan/Main.java | 2 +- src/pattypan/Session.java | 2 +- 3 files changed, 3532 insertions(+), 4474 deletions(-) diff --git a/src/org/wikipedia/Wiki.java b/src/org/wikipedia/Wiki.java index 14aa8d2..c378f0f 100644 --- a/src/org/wikipedia/Wiki.java +++ b/src/org/wikipedia/Wiki.java @@ -1,6 +1,6 @@ /** - * @(#)Wiki.java 0.36 08/02/2019 - * Copyright (C) 2007 - 2019 MER-C and contributors + * @(#)Wiki.java 0.33 10/08/2017 + * Copyright (C) 2007 - 2017 MER-C and contributors * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -22,65 +22,45 @@ import java.io.*; import java.net.*; -import java.net.http.*; import java.nio.*; -import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.text.Normalizer; import java.time.*; import java.time.format.*; -import java.time.temporal.ChronoUnit; import java.util.*; -import java.util.concurrent.*; import java.util.function.*; import java.util.logging.*; -import java.util.stream.*; +import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; import javax.security.auth.login.*; /** * This is a somewhat sketchy bot framework for editing MediaWiki wikis. - * Requires JDK 11 or greater. Uses the MediaWiki API for most * operations. It is recommended that the server runs the latest version - * of MediaWiki (1.31), otherwise some functions may not work. This framework - * requires no dependencies outside the core JDK and does not implement any - * functionality added by MediaWiki extensions. + * of MediaWiki (1.31), otherwise some functions may not work. *

* Extended documentation is available * here. * All wikilinks are relative to the English Wikipedia and all timestamps are in * your wiki's time zone. - *

+ *

* Please file bug reports here - * or at the Github issue - * tracker. - * - *

Configuration variables

- *

- * Some configuration is available through java.util.Properties. - * Set the system property wiki-java.properties to a file path - * where a configuration file is located. The available variables are: - *

+ * or at the Github issue + * tracker. * * @author MER-C and contributors - * @version 0.36 + * @version 0.33 */ -public class Wiki implements Comparable +public class Wiki implements Serializable { + // Master TODO list: + // *Admin stuff + // *More multiqueries + // *Generators (hard) + // NAMESPACES /** @@ -284,12 +264,6 @@ public class Wiki implements Comparable */ public static final String PATROL_LOG = "patrol"; - /** - * Denotes the page creation log. - * @since 0.36 - */ - public static final String PAGE_CREATION_LOG = "create"; - // PROTECTION LEVELS /** @@ -299,14 +273,14 @@ public class Wiki implements Comparable public static final String NO_PROTECTION = "all"; /** - * Denotes semi-protection (only autoconfirmed users can perform a - * action). + * 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 (only admins can perfom a particular action). + * Denotes full protection (i.e. only admins can perfom a particular action). * @since 0.09 */ public static final String FULL_PROTECTION = "sysop"; @@ -314,21 +288,21 @@ public class Wiki implements Comparable // ASSERTION MODES /** - * Use no assertions. + * Use no assertions (i.e. 0). * @see #setAssertionMode * @since 0.11 */ public static final int ASSERT_NONE = 0; /** - * Assert that we are logged in. This is checked every action. + * 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. This is checked every action. + * Assert that we have a bot flag (i.e. 2). This is checked every action. * @see #setAssertionMode * @since 0.11 */ @@ -343,41 +317,74 @@ public class Wiki implements Comparable public static final int ASSERT_NO_MESSAGES = 4; /** - * Assert that we have a sysop flag. This is checked intermittently. + * 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 {@link org.wikipedia.Wiki.Revision#diff(long) Revision.diff()}, - * denotes the next revision. - * @see org.wikipedia.Wiki.Revision#diff(long) + * In Revision.diff(), 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 {@link org.wikipedia.Wiki.Revision#diff(long) Revision.diff()}, - * denotes the current revision. - * @see org.wikipedia.Wiki.Revision#diff(long) + * In Revision.diff(), 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 {@link org.wikipedia.Wiki.Revision#diff(long) Revision.diff()}, - * denotes the previous revision. - * @see org.wikipedia.Wiki.Revision#diff(long) + * In Revision.diff(), 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. - * @see User#getGender() * @since 0.24 */ public enum Gender @@ -405,290 +412,230 @@ public enum Gender unknown; } - private static final String version = "0.36"; - - // fundamental URL strings - private final String protocol, domain, scriptPath; - private String base, articleUrl; - - /** - * Stores default HTTP parameters for API calls. Contains {@linkplain - * #setMaxLag(int) maxlag}, {@linkplain #setResolveRedirects(boolean) redirect - * resolution} and {@linkplain #setAssertionMode(int) user and bot assertions} - * when wanted by default. Add stuff to this map if you want to add parameters - * to every API call. - * @see #makeApiCall(Map, Map, String) - */ - protected ConcurrentHashMap defaultApiParams; - - /** - * URL entrypoint for the MediaWiki API. (Needs to be accessible to - * subclasses.) - * @see #initVars() - * @see #getApiUrl() - * @see MediaWiki - * documentation - */ - protected String apiUrl; + private static final String version = "0.33"; - // wiki properties - private boolean siteinfofetched = false; + // the domain of the wiki + private String domain; + protected String query, base, apiUrl; + protected String scriptPath = "/w"; private boolean wgCapitalLinks = true; - private String dbname; - private String mwVersion; - private ZoneId timezone = ZoneOffset.UTC; - private Locale locale = Locale.ENGLISH; - private List extensions = Collections.emptyList(); - private LinkedHashMap namespaces = null; - private ArrayList ns_subpages = null; + private ZoneId timezone = ZoneId.of("UTC"); // user management - private HttpClient client; - private final CookieManager cookies; + private Map cookies = new HashMap<>(12); private User user; private int statuscounter = 0; - // watchlist cache - private List watchlist = null; + // various caches + private transient LinkedHashMap namespaces = null; + private transient ArrayList ns_subpages = null; + private transient List watchlist = null; // preferences private int max = 500; private int slowmax = 50; - private int throttle = 10000; + private int throttle = 10000; // throttle private int maxlag = 5; private int assertion = ASSERT_NONE; // assertion mode - private int statusinterval = 100; // status check + private transient int statusinterval = 100; // status check private int querylimit = Integer.MAX_VALUE; 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"); // Store time when the last throttled action was executed private long lastThrottleActionTime = 0; - // config via properties - private final int maxtries; - private final int read_timeout_msec; - private final int log2_upload_size; + // retry count + private int maxtries = 2; + + // 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; + // maximum URL length in bytes + protected static final int URL_LENGTH_LIMIT = 8192; // CONSTRUCTORS AND CONFIGURATION /** - * Creates a new MediaWiki API client for the given wiki with - * $wgScriptPath set to scriptPath and via the - * specified protocol. + * Creates a new connection to the English Wikipedia via HTTPS. + * @since 0.02 + * @deprecated use Wiki#createInstance instead + */ + @Deprecated + 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 /w, you need to call + * getScriptPath() 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) + * @deprecated use Wiki#createInstance instead + */ + @Deprecated + public Wiki(String domain) + { + this(domain, "/w"); + } + + /** + * Creates a new connection to a wiki with $wgScriptpath set to + * scriptPath via HTTPS. * * @param domain the wiki domain name * @param scriptPath the script path - * @param protocol a protocol e.g. "http://", "https://" or "file:///" - * @since 0.31 + * @since 0.14 + * @deprecated use Wiki#createInstance instead */ - protected Wiki(String domain, String scriptPath, String protocol) + @Deprecated + public Wiki(String domain, String scriptPath) { - this.domain = Objects.requireNonNull(domain); - this.scriptPath = Objects.requireNonNull(scriptPath); - this.protocol = Objects.requireNonNull(protocol); - - defaultApiParams = new ConcurrentHashMap<>(); - defaultApiParams.put("format", "xml"); - defaultApiParams.put("maxlag", String.valueOf(maxlag)); + this(domain, scriptPath, "https://"); + } + /** + * Creates a new connection to a wiki with $wgScriptpath set to + * scriptPath 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 + * @deprecated use Wiki#createInstance instead; this will be made private + * for the reason in the TODO comment below + */ + @Deprecated + 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: remove this logger.setLevel(loglevel); logger.log(Level.CONFIG, "[{0}] Using Wiki.java {1}", new Object[] { domain, version }); - - // read in config - Properties props = new Properties(); - String filename = System.getProperty("wiki-java.properties"); - if (filename != null) - { - try - { - InputStream in = new FileInputStream(new File(filename)); - props.load(in); - } - catch (IOException ex) - { - logger.log(Level.WARNING, "Unable to load properties file " + filename); - } - } - maxtries = Integer.parseInt(props.getProperty("maxretries", "2")); - log2_upload_size = Integer.parseInt(props.getProperty("loguploadsize", "22")); // 4 MB - read_timeout_msec = Integer.parseInt(props.getProperty("readtimeout", "180000")); // 180 seconds - cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL); - client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .cookieHandler(cookies) - .build(); + initVars(); } - + /** - * Creates a new MediaWiki API client for the given wiki using HTTPS. - * Depending on the settings of the wiki, you may need to call {@link - * Wiki#getSiteInfo()} on the returned object after this in order for some - * functionality to work correctly. + * Creates a new connection to a wiki via HTTPS. Depending on the settings + * of the wiki, you may need to call {@link Wiki#getSiteInfo()} on the + * returned object after this in order for some functionality to work + * correctly. * * @param domain the wiki domain name e.g. en.wikipedia.org (defaults to * en.wikipedia.org) - * @return the constructed API client object + * @return the created wiki * @since 0.34 */ - public static Wiki newSession(String domain) + public static Wiki createInstance(String domain) { - return newSession(domain, "/w", "https://"); + return createInstance(domain, "/w", "https://"); } - + /** - * Creates a new MediaWiki API client for the given wiki with - * $wgScriptPath set to scriptPath and via the - * specified protocol. Depending on the settings of the wiki, you may need - * to call {@link Wiki#getSiteInfo()} on the returned object after this in + * $wgScriptPath set to scriptPath and via the + * specified protocol. Depending on the settings of the wiki, you may need + * to call {@link Wiki#getSiteInfo()} on the returned object after this in * order for some functionality to work correctly. * - *

All factory methods in subclasses must call {@link #initVars()}. - * * @param domain the wiki domain name * @param scriptPath the script path * @param protocol a protocol e.g. "http://", "https://" or "file:///" - * @return the constructed API client object + * @return the constructed Wiki object * @since 0.34 */ - public static Wiki newSession(String domain, String scriptPath, String protocol) + public static Wiki createInstance(String domain, String scriptPath, String protocol) { // Don't put network requests here. Servlets cannot afford to make // unnecessary network requests in initialization. - Wiki wiki = new Wiki(domain, scriptPath, protocol); - wiki.initVars(); + Wiki wiki = new Wiki(domain, "/w", protocol); + wiki.initVars(); // construct URL bases return wiki; } /** * Edit this if you need to change the API and human interface url - * configuration of the wiki. One example use is to change the port number. + * configuration of the wiki. One example use is server-side cache + * management (maxage and smaxage API parameters). * *

Contributed by Tedder * @since 0.24 */ protected void initVars() { - base = protocol + domain + scriptPath + "/index.php"; - apiUrl = protocol + domain + scriptPath + "/api.php"; - articleUrl = protocol + domain + "/wiki/"; + StringBuilder basegen = new StringBuilder(protocol); + basegen.append(domain); + basegen.append(scriptPath); + StringBuilder apigen = new StringBuilder(basegen); + apigen.append("/api.php?format=xml&"); + // 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. + * Gets the domain of the wiki, as supplied on construction. * @return the domain of the wiki * @since 0.06 */ - public final String getDomain() + public String getDomain() { return domain; } - - /** - * Gets the - * $wgScriptPath variable as supplied on construction. - * @return the script path of the wiki - * @since 0.14 - */ - public final String getScriptPath() - { - return scriptPath; - } - - /** - * Gets the protocol used to access this MediaWiki instance, as supplied - * on construction. - * @return (see above) - * @since 0.35 - */ - public final String getProtocol() + + public String getProtocol() { return protocol; } - /** - * Determines whether this wiki is equal to another object based on the - * protocol (not case sensitive), domain (not case sensitive) and - * scriptPath (case sensitive). A return value of {@code true} means two - * Wiki objects point to the same instance of MediaWiki. - * @param obj the object to compare - * @return whether the wikis point to the same instance of MediaWiki - */ - @Override - public boolean equals(Object obj) - { - if (!(obj instanceof Wiki)) - return false; - Wiki other = (Wiki)obj; - return domain.equalsIgnoreCase(other.domain) - && scriptPath.equals(other.scriptPath) - && protocol.equalsIgnoreCase(other.protocol); - } - - /** - * Returns a hash code of this object based on the protocol, domain and - * scriptpath. - * @return a hash code - */ - @Override - public int hashCode() - { - // English locale used here for reproducability and so network requests - // are not required - int hc = domain.toLowerCase(Locale.ENGLISH).hashCode(); - hc = 127 * hc + scriptPath.hashCode(); - hc = 127 * hc + protocol.toLowerCase(Locale.ENGLISH).hashCode(); - return hc; - } - - /** - * Allows wikis to be sorted based on their domain (case insensitive), then - * their script path (case sensitive). If 0 is returned, it is reasonable - * both Wikis point to the same instance of MediaWiki. - * @param other the wiki to compare to - * @return -1 if this wiki is alphabetically before the other, 1 if after - * and 0 if they are likely to be the same instance of MediaWiki - * @since 0.35 - */ - @Override - public int compareTo(Wiki other) - { - int result = domain.compareToIgnoreCase(other.domain); - if (result == 0) - result = scriptPath.compareTo(other.scriptPath); - return result; - } - - /** - * Gets the URL of index.php. - * @return (see above) - * @see - * MediaWiki documentation - * @since 0.35 - */ - public String getIndexPhpUrl() - { - return base; - } - - /** - * Gets the URL of api.php. - * @return (see above) - * @see MediaWiki - * documentation - * @since 0.36 - */ - public String getApiUrl() - { - return apiUrl; - } - /** * Gets the editing throttle. * @return the throttle value in milliseconds @@ -701,11 +648,8 @@ public int getThrottle() } /** - * Sets the throttle, which limits most write requests to no more than one - * per wiki instance in the given time across all threads. (As a - * consequence, all throttled methods are thread safe.) Read requests are - * not throttled or restricted in any way. Default is 10 seconds. - * + * 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 @@ -718,166 +662,69 @@ public void setThrottle(int throttle) /** * Gets various properties of the wiki and sets the bot framework up to use - * them. The return value is cached. This method is thread safe. Returns: + * them. Also populates the namespace cache. Returns: *

    *
  • usingcapitallinks: (Boolean) whether a wiki forces upper case * for the title. Example: en.wikipedia = true, en.wiktionary = false. * Default = true. See * $wgCapitalLinks - *
  • scriptpath: (String) the scriptpath: (String) the - * $wgScriptPath wiki variable. Default = {@code /w}. + * $wgScriptPath wiki variable. Default = /w. *
  • version: (String) the MediaWiki version used for this wiki - *
  • timezone: (ZoneId) the timezone the wiki is in, default = UTC - *
  • locale: (Locale) the locale of the wiki - *
  • dbname: (String) the internal name of the database + *
  • timezone: (String) the timezone the wiki is in, default = UTC *
* * @return (see above) * @since 0.30 * @throws IOException if a network error occurs - * @deprecated This method is likely going to get renamed with the return - * type changed to void once I finish cleaning up the site info caching - * mechanism. Use the specialized methods instead. - */ - @Deprecated - public synchronized Map getSiteInfo() throws IOException - { - Map siteinfo = new HashMap<>(); - if (!siteinfofetched) - { - Map getparams = new HashMap<>(); - getparams.put("action", "query"); - getparams.put("meta", "siteinfo"); - getparams.put("siprop", "namespaces|namespacealiases|general|extensions"); - String line = makeApiCall(getparams, null, "getSiteInfo"); - detectUncheckedErrors(line, null, null); - - // general site info - String bits = line.substring(line.indexOf("")); - wgCapitalLinks = parseAttribute(bits, "case", 0).equals("first-letter"); - timezone = ZoneId.of(parseAttribute(bits, "timezone", 0)); - mwVersion = parseAttribute(bits, "generator", 0); - locale = new Locale(parseAttribute(bits, "lang", 0)); - dbname = parseAttribute(bits, "wikiid", 0); - - // parse extensions - bits = line.substring(line.indexOf(""), line.indexOf("")); - extensions = new ArrayList<>(); - String[] unparsed = bits.split("(30); - ns_subpages = new ArrayList<>(30); - // xml form: Media or - String[] items = line.split("') + 1; - int c = items[i].indexOf(""); - if (c < 0) - namespaces.put("", ns); - else - namespaces.put(normalize(decode(items[i].substring(b, c))), ns); - - String canonicalnamespace = parseAttribute(items[i], "canonical", 0); - if (canonicalnamespace != null) - namespaces.put(canonicalnamespace, ns); - - // does this namespace support subpages? - if (items[i].contains("subpages=\"\"")) - ns_subpages.add(ns); - } - siteinfofetched = true; - log(Level.INFO, "getSiteInfo", "Successfully retrieved site info for " + getDomain()); - } - siteinfo.put("usingcapitallinks", wgCapitalLinks); - siteinfo.put("scriptpath", scriptPath); - siteinfo.put("timezone", timezone); - siteinfo.put("version", mwVersion); - siteinfo.put("locale", locale); - siteinfo.put("extensions", extensions); - siteinfo.put("dbname", dbname); - return siteinfo; - } - - /** - * Gets the version of MediaWiki this wiki runs e.g. 1.20wmf5 (54b4fcb). - * See [[Special:Version]] on your wiki. - * @return (see above) - * @throws UncheckedIOException if the site info cache has not been - * populated and a network error occurred when populating it - * @since 0.14 - * @see MediaWiki Git - */ - public String version() - { - ensureNamespaceCache(); - return mwVersion; - } - - /** - * Detects whether a wiki forces upper case for the first character in a - * title. Example: en.wikipedia = true, en.wiktionary = false. - * @return (see above) - * @throws UncheckedIOException if the site info cache has not been - * populated and a network error occurred when populating it - * @see MediaWiki - * documentation - * @since 0.30 - */ - public boolean usesCapitalLinks() - { - ensureNamespaceCache(); - return wgCapitalLinks; - } - - /** - * Returns the list of extensions installed on this wiki. - * @return (see above) - * @throws UncheckedIOException if the site info cache has not been - * populated and a network error occurred when populating it - * @see MediaWiki - * documentation - * @since 0.35 - */ - public List installedExtensions() - { - ensureNamespaceCache(); - return new ArrayList<>(extensions); - } - - /** - * Gets the timezone of this wiki - * @return (see above) - * @throws UncheckedIOException if the site info cache has not been - * populated and a network error occurred when populating it - * @since 0.35 - */ - public ZoneId timezone() - { - ensureNamespaceCache(); - return timezone; - } - - /** - * Gets the locale of this wiki. - * @return (see above) - * @throws UncheckedIOException if the site info cache has not been - * populated and a network error occurred when populating it - * @since 0.35 */ - public Locale locale() + public Map getSiteInfo() throws IOException { - ensureNamespaceCache(); - return locale; + Map ret = new HashMap<>(); + String line = fetch(query + "action=query&meta=siteinfo&siprop=namespaces%7Cnamespacealiases%7Cgeneral", "getSiteInfo"); + + // general site info + String bits = line.substring(line.indexOf("")); + wgCapitalLinks = parseAttribute(bits, "case", 0).equals("first-letter"); + ret.put("usingcapitallinks", wgCapitalLinks); + scriptPath = parseAttribute(bits, "scriptpath", 0); + ret.put("scriptpath", scriptPath); + timezone = ZoneId.of(parseAttribute(bits, "timezone", 0)); + ret.put("timezone", timezone); + ret.put("version", parseAttribute(bits, "generator", 0)); + + // populate namespace cache + namespaces = new LinkedHashMap<>(30); + ns_subpages = new ArrayList<>(30); + // xml form: Media or + String[] items = line.split("') + 1; + int c = items[i].indexOf(""); + if (c < 0) + namespaces.put("", ns); + else + namespaces.put(normalize(decode(items[i].substring(b, c))), ns); + + String canonicalnamespace = parseAttribute(items[i], "canonical", 0); + if (canonicalnamespace != null) + namespaces.put(canonicalnamespace, ns); + + // does this namespace support subpages? + if (items[i].contains("subpages=\"\"")) + ns_subpages.add(ns); + } + + initVars(); + log(Level.INFO, "getSiteInfo", "Successfully retrieved site info for " + getDomain()); + return ret; } /** @@ -906,9 +753,7 @@ public String getUserAgent() * Enables/disables GZip compression for GET requests. Default: true. * @param zipped whether we use GZip compression * @since 0.23 - * @deprecated this is now handled transparently; just delete calls to this method. */ - @Deprecated(forRemoval=true) public void setUsingCompressedRequests(boolean zipped) { this.zipped = zipped; @@ -919,9 +764,7 @@ public void setUsingCompressedRequests(boolean zipped) * Default: true. * @return (see above) * @since 0.23 - * @deprecated this is now handled transparently; just delete calls to this method. */ - @Deprecated(forRemoval=true) public boolean isUsingCompressedRequests() { return zipped; @@ -947,10 +790,7 @@ public boolean isResolvingRedirects() public void setResolveRedirects(boolean b) { resolveredirect = b; - if (b) - defaultApiParams.put("redirects", "1"); - else - defaultApiParams.remove("redirects"); + initVars(); } /** @@ -1023,6 +863,31 @@ public void setQueryLimit(int limit) querylimit = limit; } + /** + * Determines whether this wiki is equal to another object. + * @param obj the object to compare + * @return whether the domains of the wikis are equal + * @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. @@ -1032,14 +897,12 @@ public void setQueryLimit(int limit) public String toString() { // domain - StringBuilder buffer = new StringBuilder("Wiki[url="); - buffer.append(protocol); + StringBuilder buffer = new StringBuilder("Wiki[domain="); buffer.append(domain); - buffer.append(scriptPath); // user buffer.append(",user="); - buffer.append(Objects.toString(user)); + buffer.append(user != null ? user.toString() : "null"); buffer.append(","); // throttle mechanisms @@ -1085,10 +948,7 @@ public void setMaxLag(int lag) { maxlag = lag; log(Level.CONFIG, "setMaxLag", "Setting maximum allowable database lag to " + lag); - if (maxlag >= 0) - defaultApiParams.put("maxlag", String.valueOf(maxlag)); - else - defaultApiParams.remove("maxlag"); + initVars(); } /** @@ -1113,13 +973,7 @@ public void setAssertionMode(int mode) { assertion = mode; log(Level.CONFIG, "setAssertionMode", "Set assertion mode to " + mode); - - if ((assertion & ASSERT_BOT) == ASSERT_BOT) - defaultApiParams.put("assert", "bot"); - else if ((assertion & ASSERT_USER) == ASSERT_USER) - defaultApiParams.put("assert", "user"); - else - defaultApiParams.remove("assert"); + initVars(); } /** @@ -1167,36 +1021,29 @@ public void setLogLevel(Level loglevel) // META STUFF /** - * Logs in to the wiki. This method is thread-safe. + * Logs in to the wiki. This method is thread-safe. * * @param username a username - * @param password a password, as a {@code char[]} for security - * reasons. Overwritten once the password is used. + * @param password a password (as a char[] due to JPasswordField) * @throws IOException if a network error occurs - * @throws FailedLoginException if the login failed due to an incorrect - * username or password, the requirement for an interactive login (not - * supported, use [[Special:BotPasswords]]) or some other reason * @see #logout - * @see MediaWiki - * documentation */ public synchronized void login(String username, char[] password) throws IOException, FailedLoginException { - Map getparams = new HashMap<>(); - getparams.put("action", "login"); - Map postparams = new HashMap<>(); - postparams.put("lgname", username); - postparams.put("lgpassword", new String(password)); - postparams.put("lgtoken", getToken("login")); - String line = makeApiCall(getparams, postparams, "login"); - detectUncheckedErrors(line, null, null); - Arrays.fill(password, '0'); + StringBuilder buffer = new StringBuilder(500); + buffer.append("lgname="); + buffer.append(encode(username, false)); + buffer.append("&lgpassword="); + buffer.append(encode(new String(password), false)); + buffer.append("&lgtoken="); + buffer.append(encode(getToken("login"), false)); + String line = post(apiUrl + "action=login", buffer.toString(), "login"); + buffer.setLength(0); // check for success if (line.contains("result=\"Success\"")) { - String returned_username = parseAttribute(line, "lgusername", 0); - user = getUsers(List.of(returned_username)).get(0); + user = new User(parseAttribute(line, "lgusername", 0)); boolean apihighlimit = user.isAllowedTo("apihighlimits"); if (apihighlimit) { @@ -1208,21 +1055,18 @@ public synchronized void login(String username, char[] password) throws IOExcept else if (line.contains("result=\"Failed\"")) throw new FailedLoginException("Login failed: " + parseAttribute(line, "reason", 0)); // interactive login or bot password required - else if (line.contains("result=\"Aborted\"")) + else if (line.contains("result=\"Aborted\"")) throw new FailedLoginException("Login failed: you need to use a bot password, see [[Special:Botpasswords]]."); else throw new AssertionError("Unreachable!"); } /** - * Logs in to the wiki. This method is thread-safe. + * Logs in to the wiki. This method is thread-safe. * * @param username a username * @param password a string with the password * @throws IOException if a network error occurs - * @throws FailedLoginException if the login failed due to an incorrect - * username or password, the requirement for an interactive login (not - * supported, use [[Special:Botpasswords]]) or some other reason * @see #logout */ public synchronized void login(String username, String password) throws IOException, FailedLoginException @@ -1239,7 +1083,7 @@ public synchronized void login(String username, String password) throws IOExcept */ public synchronized void logout() { - cookies.getCookieStore().removeAll(); + cookies.clear(); user = null; max = 500; slowmax = 50; @@ -1258,15 +1102,10 @@ public synchronized void logout() * @since 0.14 * @see #login * @see #logout - * @see MediaWiki - * documentation */ public synchronized void logoutServerSide() throws IOException { - Map getparams = new HashMap<>(); - getparams.put("action", "logout"); - String response = makeApiCall(getparams, null, "logoutServerSide"); - detectUncheckedErrors(response, null, null); + fetch(apiUrl + "action=logout", "logoutServerSide"); logout(); // destroy local cookies } @@ -1279,18 +1118,12 @@ public synchronized void logoutServerSide() throws IOException */ public boolean hasNewMessages() throws IOException { - Map getparams = new HashMap<>(); - getparams.put("action", "query"); - getparams.put("meta", "userinfo"); - getparams.put("uiprop", "hasmsg"); - String response = makeApiCall(getparams, null, "hasNewMessages"); - detectUncheckedErrors(response, null, null); - return response.contains("messages=\"\""); + String url = query + "meta=userinfo&uiprop=hasmsg"; + return fetch(url, "hasNewMessages").contains("messages=\"\""); } /** - * Determines the current database replication lag. This method does not - * wait if the maxlag setting is exceeded. This method is thread safe. + * Determines the current database replication lag. * @return the current database replication lag * @throws IOException if a network error occurs * @see #setMaxLag @@ -1299,26 +1132,12 @@ public boolean hasNewMessages() throws IOException * MediaWiki documentation * @since 0.10 */ - public double getCurrentDatabaseLag() throws IOException + public int getCurrentDatabaseLag() throws IOException { - Map getparams = new HashMap<>(); - getparams.put("action", "query"); - getparams.put("meta", "siteinfo"); - getparams.put("siprop", "dbrepllag"); - - synchronized (this) - { - // bypass lag check for this request - int temp = getMaxLag(); - setMaxLag(-1); - String line = makeApiCall(getparams, null, "getCurrentDatabaseLag"); - detectUncheckedErrors(line, null, null); - setMaxLag(temp); - - String lag = parseAttribute(line, "lag", 0); - log(Level.INFO, "getCurrentDatabaseLag", "Current database replication lag is " + lag + " seconds"); - return Double.parseDouble(lag); - } + 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); } /** @@ -1333,12 +1152,7 @@ public double getCurrentDatabaseLag() throws IOException */ public Map getSiteStatistics() throws IOException { - Map getparams = new HashMap<>(); - getparams.put("action", "query"); - getparams.put("meta", "siteinfo"); - getparams.put("siprop", "statistics"); - String text = makeApiCall(getparams, null, "getSiteStatistics"); - detectUncheckedErrors(text, null, null); + String text = fetch(query + "meta=siteinfo&siprop=statistics", "getSiteStatistics"); Map ret = new HashMap<>(20); ret.put("pages", Integer.parseInt(parseAttribute(text, "pages", 0))); ret.put("articles", Integer.parseInt(parseAttribute(text, "articles", 0))); @@ -1351,8 +1165,10 @@ public Map getSiteStatistics() throws IOException } /** - * Renders the specified wiki markup as HTML by passing it to the MediaWiki - * parser through the API. + * 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 @@ -1361,99 +1177,32 @@ public Map getSiteStatistics() throws IOException */ public String parse(String markup) throws IOException { - Map content = new HashMap<>(); - content.put("text", markup); - return parse(content, -1, false); + // 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=" + encode(markup, false), "parse"); + int y = response.indexOf('>', response.indexOf(""); + return decode(response.substring(y, z)); } /** - * Parses wikitext, revisions or pages. Deleted pages and revisions to - * deleted pages are not allowed if you don't have the rights to view them. - * - *

- * The returned HTML does not include "edit" links. Hyperlinks are - * rewritten from useless relative links to other wiki pages to full URLs. - * References to resources using protocol relative URLs are rewritten to - * use {@linkplain #getProtocol() this wiki's protocol}. - * - *

- * Warnings: - *

    - *
  • The parameters to this method will be changed when the time comes - * for JDK11 refactoring to accept Map.Entry instead. I also haven't - * decided how many more boolean parameters to add, and what format - * they will take. - *
+ * Same as {@link #parse(java.lang.String)}, but also strips out unwanted + * crap. This might be useful to subclasses. * - * @param content a Map following the same scheme as specified by {@link - * #diff(Map, Map)} - * @param section parse only this section (optional, use -1 to skip) - * @param nolimitreport do not include the HTML comment detailing limits - * @return the parsed wikitext - * @throws NoSuchElementException or IllegalArgumentException if no content - * was supplied for parsing - * @throws SecurityException if you pass a RevisionDeleted revision and - * lack the necessary privileges - * @throws IOException if a network error occurs - * @see #parse(String) - * @see #getRenderedText(String) - * @see Wiki.Revision#getRenderedText() - * @see MediaWiki - * documentation - * @since 0.35 - */ - public String parse(Map content, int section, boolean nolimitreport) throws IOException - { - Map getparams = new HashMap<>(); - getparams.put("action", "parse"); - getparams.put("prop", "text"); - if (nolimitreport) - getparams.put("disablelimitreport", "1"); - getparams.put("disableeditsection", "1"); - Map postparams = new HashMap<>(); - - Map.Entry entry = content.entrySet().iterator().next(); - Object value = entry.getValue(); - switch (entry.getKey()) - { - case "title": - getparams.put("page", normalize((String)value)); - break; - case "revid": - getparams.put("oldid", value.toString()); - break; - case "revision": - getparams.put("oldid", String.valueOf(((Revision)value).getID())); - break; - case "text": - postparams.put("text", value); - break; - default: - throw new IllegalArgumentException("No content was specified to parse!"); - } - if (section >= 0) - getparams.put("section", String.valueOf(section)); - - String response = makeApiCall(getparams, postparams, "parse"); - Consumer noop = desc -> {}; - Map> warnings = Map.of( - "missingtitle", noop, - "missingcontent", noop, - "nosuchsection", noop, - "nosuchrevid", noop); - if (detectUncheckedErrors(response, null, warnings)) - { - int y = response.indexOf('>', response.indexOf(""); - - // Rewrite URLs to replace useless relative links and make images work on - // locally saved copies of wiki pages. - String html = decode(response.substring(y, z)); - html = html.replace("href=\"/wiki", "href=\"" + protocol + domain + "/wiki"); - html = html.replace(" src=\"//", " src=\"" + protocol); // a little fragile for my liking, but will do - return html; - } - return null; + * @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("

", "").replace("

", ""); // remove paragraph tags + output = output.replace("\n", ""); // remove new lines + + // strip out the parser report, which comes at the end + int a = output.indexOf("" nocreate="" allowusertalk=""/> - for (int a = line.indexOf(" 0; a = line.indexOf("", a), line.indexOf("", a)); - String temp = line.substring(a, b); - - String blocker = parseAttribute(temp, "by", 0); - String blockeduser = parseAttribute(temp, "user", 0); - String target; - if (blockeduser == null) // autoblock - target = "#" + parseAttribute(temp, "id", 0); - else - target = namespaceIdentifier(USER_NAMESPACE) + ":" + blockeduser; + url.append("&bkstart="); + url.append(start.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + if (!user.isEmpty()) + { + url.append("&bkusers="); + url.append(user); + } - LogEntry le = parseLogEntry(temp, blocker, BLOCK_LOG, "block", target); - results.add(le); - } - }; - List entries = new ArrayList<>(); - if (users == null) - entries.addAll(makeListQuery("bk", getparams, null, "getBlockList", limit, parser)); - else + // connection + List entries = queryAPIResult("bk", url, "getIPBlockList", (line, results) -> { - for (String bkusers : constructTitleString(users)) + try + { + // XML form: + for (int a = line.indexOf(" 0; a = line.indexOf("", a); + String temp = line.substring(a, b); + LogEntry le = parseLogEntry(temp); + le.type = BLOCK_LOG; + le.action = "block"; + // parseLogEntries parses block target into le.user due to mw.api + // attribute name + if (le.user == null) // autoblock + le.target = "#" + parseAttribute(temp, "id", 0); + else + le.target = namespaceIdentifier(USER_NAMESPACE) + ":" + le.user.username; + // parse blocker for real + le.user = new User(parseAttribute(temp, "by", 0)); + results.add(le); + } + } + catch (IOException ex) { - getparams.put("bkusers", bkusers); - entries.addAll(makeListQuery("bk", getparams, null, "getBlockList", limit, parser)); + throw new UncheckedIOException(ex); } + }); + + // log statement + StringBuilder logRecord = new StringBuilder("Successfully fetched IP block list "); + if (!user.isEmpty()) + { + logRecord.append(" for "); + logRecord.append(user); + } + if (start != null) + { + logRecord.append(" from "); + logRecord.append(start.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + if (end != null) + { + logRecord.append(" to "); + logRecord.append(end.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); } - log(Level.INFO, "getBlockList", "Successfully fetched block list " + entries.size() + " entries)"); - return entries; + int size = entries.size(); + logRecord.append(" ("); + logRecord.append(size); + logRecord.append(" entries)"); + log(Level.INFO, "getIPBlockList", logRecord.toString()); + return entries.toArray(new LogEntry[size]); + } + + /** + * Gets the log entries representing actions that were performed on a + * specific target. Equivalent to [[Special:Log]]. + * @param logtype what log to get (e.g. {@link #DELETION_LOG}) + * @param action what action to get (e.g. delete, undelete, etc.), use + * null to not specify one + * @param user the user performing the action. Use null not to specify + * one. + * @param target the target of the action(s). + * @throws IOException if a network error occurs + * @return the specified log entries + * @since 0.08 + */ + public LogEntry[] getLogEntries(String logtype, String action, String user, String target) throws IOException + { + return getLogEntries(logtype, action, user, target, null, null, Integer.MAX_VALUE, ALL_NAMESPACES); + } + + /** + * Gets the last how ever many log entries in the specified log. Equivalent + * to [[Special:Log]] and [[Special:Newimages]] when + * type.equals({@link #UPLOAD_LOG}). + * + * @param logtype what log to get (e.g. {@link #DELETION_LOG}) + * @param action what action to get (e.g. delete, undelete, etc.), use + * null to not specify one + * @param amount the number of entries to get (overrides global limits) + * @throws IOException if a network error occurs + * @throws IllegalArgumentException if the log type doesn't exist + * @return the specified log entries + */ + public LogEntry[] getLogEntries(String logtype, String action, int amount) throws IOException + { + return getLogEntries(logtype, action, null, null, null, null, amount, ALL_NAMESPACES); } /** * Gets the specified amount of log entries between the given times by * the given user on the given target. Equivalent to [[Special:Log]]. - * Accepted parameters from helper are: - * - *
    - *
  • {@link Wiki.RequestHelper#withinDateRange(OffsetDateTime, - * OffsetDateTime) date range} - *
  • {@link Wiki.RequestHelper#byUser(String) user} - *
  • {@link Wiki.RequestHelper#byTitle(String) title} - *
  • {@link Wiki.RequestHelper#reverse(boolean) reverse} - *
  • {@link Wiki.RequestHelper#inNamespaces(int...) namespaces} (one - * namespace only, must not be used if a title is specified) - *
  • {@link Wiki.RequestHelper#taggedWith(String) tag} - *
  • {@link Wiki.RequestHelper#limitedTo(int) local query limit} - *
+ * WARNING: the start date is the most recent of the dates given, and + * the order of enumeration is from newest to oldest. * * @param logtype what log to get (e.g. {@link #DELETION_LOG}) - * @param action what action to get (e.g. delete, undelete, etc.), use - * {@code null} to not specify one - * @param helper a {@link Wiki.RequestHelper} (optional, use null to not - * provide any of the optional parameters noted above) + * @param action what action to get (e.g. delete, undelete, etc.), use + * null to not specify one + * @param user the user performing the action. Use null not to specify + * one. + * @param target the target of the action. Use null not to specify one. + * @param start what timestamp to start. Use null to not specify one. + * @param end what timestamp to end. Use null to not specify one. + * @param amount the amount of log entries to get. If both start and + * end are defined, this is ignored. Use Integer.MAX_VALUE to not + * specify one (overrides global limits) + * @param namespace filters by namespace. Returns empty if namespace + * doesn't exist. Use {@link #ALL_NAMESPACES} to not specify one. * @throws IOException if a network error occurs - * @throws SecurityException if the user lacks the credentials needed to - * access a privileged log + * @throws IllegalArgumentException if start < end or amount < 1 * @return the specified log entries * @since 0.08 */ - public List getLogEntries(String logtype, String action, Wiki.RequestHelper helper) throws IOException + public LogEntry[] getLogEntries(String logtype, String action, String user, String target, + OffsetDateTime start, OffsetDateTime end, int amount, int namespace) throws IOException { - int limit = -1; - Map getparams = new HashMap<>(); - getparams.put("list", "logevents"); - getparams.put("leprop", "ids|title|type|user|timestamp|comment|parsedcomment|details|tags"); + // construct the query url from the parameters given + StringBuilder url = new StringBuilder(query); + url.append("list=logevents&leprop=ids%7Ctitle%7Ctype%7Cuser%7Ctimestamp%7Ccomment%7Cdetails"); + + // check for amount + if (amount < 1) + throw new IllegalArgumentException("Tried to retrieve less than one log entry!"); + if (!logtype.equals(ALL_LOGS)) { if (action == null) - getparams.put("letype", logtype); + { + url.append("&letype="); + url.append(logtype); + } else - getparams.put("leaction", logtype + "/" + action); + { + url.append("&leaction="); + url.append(logtype); + url.append("/"); + url.append(action); + } + } + if (namespace != ALL_NAMESPACES) + { + url.append("&lenamespace="); + url.append(namespace); + } + if (user != null) + { + url.append("&leuser="); + url.append(encode(user, true)); + } + if (target != null) + { + url.append("&letitle="); + url.append(encode(target, true)); } - if (helper != null) + if (start != null) { - helper.setRequestType("le"); - getparams.putAll(helper.addTitleParameter()); - getparams.putAll(helper.addDateRangeParameters()); - getparams.putAll(helper.addUserParameter()); - getparams.putAll(helper.addReverseParameter()); - getparams.putAll(helper.addNamespaceParameter()); - getparams.putAll(helper.addTagParameter()); - limit = helper.limit(); + if (end != null && start.isBefore(end)) //aargh + throw new IllegalArgumentException("Specified start date is before specified end date!"); + url.append("&lestart="); + url.append(start.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + if (end != null) + { + url.append("&leend="); + url.append(end.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); } - List entries = makeListQuery("le", getparams, null, "getLogEntries", limit, (line, results) -> + int originallimit = getQueryLimit(); + setQueryLimit(Math.min(amount, originallimit)); + List entries = queryAPIResult("le", url, "getLogEntries", (line, results) -> { String[] items = line.split("getLogEntries(), - * getImageHistory() and getBlockList() into {@link Wiki.LogEntry} + * Parses xml generated by getLogEntries(), + * getImageHistory() and getIPBlockList() into LogEntry * objects. Override this if you want custom log types. NOTE: if * RevisionDelete was used on a log entry, the relevant values will be * null. * * @param xml the xml to parse - * @param user null, or use this value for the performer of the log entry - * @param type null, or use this value for the type of the log entry - * @param action null, or use this value for the action of the log entry - * @param target null, or user this value for the target of the log entry * @return the parsed log entry * @since 0.18 */ - protected LogEntry parseLogEntry(String xml, String user, String type, String action, String target) + protected LogEntry parseLogEntry(String xml) { - // ID (getLogEntries only) - long id = -1; - if (xml.contains("logid=\"")) - id = Long.parseLong(parseAttribute(xml, "logid", 0)); - + // note that we can override these in the calling method + String type = "", action = ""; boolean actionhidden = xml.contains("actionhidden=\""); - if (type == null && xml.contains("type=\"")) // only getLogEntries + if (xml.contains("type=\"")) // only getLogEntries { type = parseAttribute(xml, "type", 0); action = parseAttribute(xml, "action", 0); } // reason - String reason, parsedreason; + String reason; boolean reasonhidden = xml.contains("commenthidden=\""); - if (USER_CREATION_LOG.equals(type)) - { - // there is no reason for creating a user + if (type.equals(USER_CREATION_LOG)) // there is no reason for creating a user reason = ""; - parsedreason = ""; - } else if (xml.contains("reason=\"")) - { reason = parseAttribute(xml, "reason", 0); - parsedreason = null; // not available in list=blocks / getBlockList! - } else - { reason = parseAttribute(xml, "comment", 0); - parsedreason = parseAttribute(xml, "parsedcomment", 0); - } - // generic performer name + // generic performer name (won't work for ipblocklist, overridden there) boolean userhidden = xml.contains("userhidden=\"\""); - if (user == null && xml.contains("user=\"")) - user = parseAttribute(xml, "user", 0); + User performer = null; + if (xml.contains("user=\"")) + performer = new User(parseAttribute(xml, "user", 0)); // generic target name - // space is important -- commons.getImageHistory("File:Chief1.gif"); - if (target == null && xml.contains(" title=\"")) + String target = null; + if (xml.contains(" title=\"")) // space is important -- commons.getImageHistory("File:Chief1.gif"); target = parseAttribute(xml, "title", 0); OffsetDateTime timestamp = OffsetDateTime.parse(parseAttribute(xml, "timestamp", 0)); - // details - Map details = new HashMap<>(); + // details: TODO: make this a HashMap + Object details = null; if (xml.contains("commenthidden")) // oversighted details = null; else if (type.equals(MOVE_LOG)) - details.put("target_title", parseAttribute(xml, "target_title", 0)); + details = parseAttribute(xml, "target_title", 0); // the new title else if (type.equals(BLOCK_LOG) || xml.contains(" 10) // not an unblock { int d = s.indexOf('\"', c); - if (s.contains("anononly")) // anon-only - details.put("anononly", "true"); - if (s.contains("nocreate")) // account creation blocked - details.put("nocreate", "true"); - if (s.contains("noautoblock")) // autoblock disabled - details.put("noautoblock", "true"); - if (s.contains("noemail")) // email disabled - details.put("noemail", "true"); - if (s.contains("nousertalk")) // cannot edit talk page - details.put("nousertalk", "true"); - details.put("expiry", s.substring(c, d)); - - // partial block parameters - if (s.contains("")) + details = new Object[] { - details.put("partial", "true"); - if (s.contains("")) - { - details.put("type", "namespaces"); - // TODO: add the actual namespaces - } - else if (s.contains("")) - { - details.put("type", "pages"); - // TODO: add the actual pages - } - } + s.contains("anononly"), // anon-only + s.contains("nocreate"), // account creation blocked + s.contains("noautoblock"), // autoblock disabled + s.contains("noemail"), // email disabled + s.contains("nousertalk"), // cannot edit talk page + s.substring(c, d) // duration + }; } } else if (type.equals(PROTECTION_LOG)) { - if (action.equals("protect")) + if (action.equals("unprotect")) + details = null; + else { // FIXME: return a protectionstate here? - details.put("protection string", parseAttribute(xml, "description", 0)); + int a = xml.indexOf("") + 7; + int b = xml.indexOf("", a); + details = xml.substring(a, b); } } else if (type.equals(USER_RENAME_LOG)) { int a = xml.indexOf("") + 7; int b = xml.indexOf("", a); - details.put("new username", decode(xml.substring(a, b))); + details = decode(xml.substring(a, b)); // the new username } else if (type.equals(USER_RIGHTS_LOG)) { - int a = xml.indexOf(""); - if (a != -1) { - int b = xml.indexOf("", a); - var oldgroups = xml.substring(a + 11, b); - var old_list = new ArrayList(); - for (int end = 0, start = oldgroups.indexOf(""); start != -1; start = oldgroups.indexOf("", end)) { - end = oldgroups.indexOf("", start); - old_list.add(oldgroups.substring(start + 3, end)); - } - details.put("oldgroups", String.join(",", old_list)); - } else // self-closing empty "" tag - details.put("oldgroups", ""); - int c = xml.indexOf(""); - if (c != -1) { - int d = xml.indexOf("", c); - var newgroups = xml.substring(c + 11, d); - var new_list = new ArrayList(); - for (int end = 0, start = newgroups.indexOf(""); start != -1; start = newgroups.indexOf("", end)) { - end = newgroups.indexOf("", start); - new_list.add(newgroups.substring(start + 3, end)); - } - details.put("newgroups", String.join(",", new_list)); - } else // self-closing empty "" tag - details.put("newgroups", ""); - } - - // tags - List tags = new ArrayList<>(); - if (xml.contains("")) - { - for (int idx = xml.indexOf(""); idx >= 0; idx = xml.indexOf("", ++idx)) - tags.add(xml.substring(idx + 5, xml.indexOf("", idx))); + int a = xml.indexOf("new=\"") + 5; + int b = xml.indexOf('\"', a); + StringTokenizer tk = new StringTokenizer(xml.substring(a, b), ", "); + List temp = new ArrayList<>(); + while (tk.hasMoreTokens()) + temp.add(tk.nextToken()); + details = temp.toArray(new String[temp.size()]); } - - LogEntry le = new LogEntry(id, timestamp, user, reason, parsedreason, type, action, target, details); - le.setUserDeleted(userhidden); - le.setCommentDeleted(reasonhidden); - le.setContentDeleted(actionhidden); - le.setTags(tags); + + LogEntry le = new LogEntry(type, action, reason, performer, target, timestamp, details); + le.userDeleted = userhidden; + le.reasonDeleted = reasonhidden; + le.targetDeleted = actionhidden; return le; } @@ -6085,7 +5424,7 @@ else if (type.equals(USER_RIGHTS_LOG)) * @throws IOException if a network error occurs * @since 0.15 */ - public List prefixIndex(String prefix) throws IOException + public String[] prefixIndex(String prefix) throws IOException { return listPages(prefix, null, ALL_NAMESPACES, -1, -1, null); } @@ -6098,7 +5437,7 @@ public List prefixIndex(String prefix) throws IOException * @throws IOException if a network error occurs * @since 0.15 */ - public List shortPages(int cutoff) throws IOException + public String[] shortPages(int cutoff) throws IOException { return listPages("", null, MAIN_NAMESPACE, -1, cutoff, null); } @@ -6112,7 +5451,7 @@ public List shortPages(int cutoff) throws IOException * @return pages below that size in that namespace * @since 0.15 */ - public List shortPages(int cutoff, int namespace) throws IOException + public String[] shortPages(int cutoff, int namespace) throws IOException { return listPages("", null, namespace, -1, cutoff, null); } @@ -6125,7 +5464,7 @@ public List shortPages(int cutoff, int namespace) throws IOException * @throws IOException if a network error occurs * @since 0.15 */ - public List longPages(int cutoff) throws IOException + public String[] longPages(int cutoff) throws IOException { return listPages("", null, MAIN_NAMESPACE, cutoff, -1, null); } @@ -6139,7 +5478,7 @@ public List longPages(int cutoff) throws IOException * @throws IOException if a network error occurs * @since 0.15 */ - public List longPages(int cutoff, int namespace) throws IOException + public String[] longPages(int cutoff, int namespace) throws IOException { return listPages("", null, namespace, cutoff, -1, null); } @@ -6161,7 +5500,7 @@ public List longPages(int cutoff, int namespace) throws IOException * @since 0.09 * @throws IOException if a network error occurs */ - public List listPages(String prefix, Map protectionstate, int namespace) throws IOException + public String[] listPages(String prefix, Map protectionstate, int namespace) throws IOException { return listPages(prefix, protectionstate, namespace, -1, -1, null); } @@ -6179,7 +5518,7 @@ public List listPages(String prefix, Map protectionstate * @param protectionstate a {@link #protect protection state}, use null * to not specify one * @param namespace a namespace. ALL_NAMESPACES is not suppported, an - * UnsupportedOperationException will be thrown unless a prefix is specified. + * UnsupportedOperationException will be thrown. * @param minimum the minimum size in bytes these pages can be. Use -1 to * not specify one. * @param maximum the maximum size in bytes these pages can be. Use -1 to @@ -6190,96 +5529,116 @@ public List listPages(String prefix, Map protectionstate * @since 0.09 * @throws IOException if a network error occurs */ - public List listPages(String prefix, Map protectionstate, int namespace, int minimum, + public String[] listPages(String prefix, Map protectionstate, int namespace, int minimum, int maximum, Boolean redirects) throws IOException { + // @revised 0.15 to add short/long pages // No varargs namespace here because MW API only supports one namespace // for this module. - Map getparams = new HashMap<>(); - getparams.put("list", "allpages"); - if (!prefix.isEmpty()) + StringBuilder url = new StringBuilder(query); + url.append("list=allpages"); + if (!prefix.isEmpty()) // prefix { - if (namespace == ALL_NAMESPACES) - { - namespace = namespace(prefix); - prefix = removeNamespace(prefix); - } - getparams.put("apprefix", normalize(prefix)); + // cull the namespace prefix + namespace = namespace(prefix); + if (prefix.contains(":") && namespace != MAIN_NAMESPACE) + prefix = prefix.substring(prefix.indexOf(':') + 1); + url.append("&apprefix="); + url.append(encode(prefix, true)); } else if (namespace == ALL_NAMESPACES) // check for namespace throw new UnsupportedOperationException("ALL_NAMESPACES not supported in MediaWiki API."); - getparams.put("apnamespace", String.valueOf(namespace)); + url.append("&apnamespace="); + url.append(namespace); if (protectionstate != null) { - StringBuilder apprtype = new StringBuilder(); - StringBuilder apprlevel = new StringBuilder(); - protectionstate.forEach((key, value) -> + StringBuilder apprtype = new StringBuilder("&apprtype="); + StringBuilder apprlevel = new StringBuilder("&apprlevel="); + for (Map.Entry entry : protectionstate.entrySet()) { + String key = entry.getKey(); if (key.equals("cascade")) - getparams.put("apprfiltercascade", (Boolean)value ? "cascading" : "noncascading"); + { + url.append("&apprfiltercascade="); + url.append((Boolean)entry.getValue() ? "cascading" : "noncascading"); + } else if (!key.contains("expiry")) { apprtype.append(key); - apprtype.append("|"); - apprlevel.append(value); - apprlevel.append("|"); + apprtype.append("%7C"); + apprlevel.append((String)entry.getValue()); + apprlevel.append("%7C"); } - }); - getparams.put("apprtype", apprtype.substring(0, apprtype.length() - 1)); - getparams.put("apprlevel", apprlevel.substring(0, apprlevel.length() - 1)); + } + apprtype.delete(apprtype.length() - 3, apprtype.length()); + apprlevel.delete(apprlevel.length() - 3, apprlevel.length()); + url.append(apprtype); + url.append(apprlevel); } // max and min - if (minimum >= 0) - getparams.put("apminsize", String.valueOf(minimum)); - if (maximum >= 0) - getparams.put("apmaxsize", String.valueOf(maximum)); + if (minimum != -1) + { + url.append("&apminsize="); + url.append(minimum); + } + if (maximum != -1) + { + url.append("&apmaxsize="); + url.append(maximum); + } if (redirects != null) - getparams.put("apfilterredir", redirects ? "redirects" : "nonredirects"); + { + url.append("&apfilterredir="); + url.append(redirects ? "redirects" : "nonredirects"); + } // set query limit = 1 request if max, min, prefix or protection level // not specified - int limit = -1; + int originallimit = getQueryLimit(); if (maximum < 0 && minimum < 0 && prefix.isEmpty() && protectionstate == null) - limit = max; - List pages = makeListQuery("ap", getparams, null, "listPages", limit, (line, results) -> + setQueryLimit(max); + List pages = queryAPIResult("ap", url, "listPages", (line, results) -> { // xml form:

for (int a = line.indexOf("

0; a = line.indexOf("

Warnings: - *

- * - * @param page one of the qppage values specifed by the documentation below - * (case sensitive) + * WARNING: some of these may be *CACHED*, *DISABLED* and/or *LIMITED* on + * large wikis. + * + * @param page one of { Ancientpages, BrokenRedirects, Deadendpages, + * Disambiguations, DoubleRedirects, Listredirects, Lonelypages, Longpages, + * Mostcategories, Mostimages, Mostinterwikis, Mostlinkedcategories, + * Mostlinkedtemplates, Mostlinked, Mostrevisions, Fewestrevisions, Shortpages, + * Uncategorizedcategories, Uncategorizedpages, Uncategorizedimages, + * Uncategorizedtemplates, Unusedcategories, Unusedimages, Wantedcategories, + * Wantedfiles, Wantedpages, Wantedtemplates, Unwatchedpages, Unusedtemplates, + * Withoutinterwiki }. This parameter is *case sensitive*. * @return the list of pages returned by that particular special page * @throws IOException if a network error occurs - * @throws SecurityException if the user lacks the privileges necessary to - * view a report (e.g. unwatchedpages) + * @throws CredentialNotFoundException if page=Unwatchedpages and we cannot + * read it * @since 0.28 - * @see MediaWiki - * documentation */ - public List queryPage(String page) throws IOException + public String[] queryPage(String page) throws IOException, CredentialNotFoundException { - Map getparams = new HashMap<>(); - getparams.put("list", "querypage"); - getparams.put("qppage", page); + if (page.equals("Unwatchedpages") && (user == null || !user.isAllowedTo("unwatchedpages"))) + throw new CredentialNotFoundException("User does not have the \"unwatchedpages\" permission."); - List pages = makeListQuery("qp", getparams, null, "queryPage", -1, (line, results) -> + StringBuilder url = new StringBuilder(query); + url.append("action=query&list=querypage&qppage="); + url.append(page); + + List pages = queryAPIResult("qp", url, "queryPage", (line, results) -> { // xml form: for (int x = line.indexOf(" 0; x = line.indexOf(" queryPage(String page) throws IOException int temp = pages.size(); log(Level.INFO, "queryPage", "Successfully retrieved [[Special:" + page + "]] (" + temp + " pages)"); - return pages; + return pages.toArray(new String[temp]); } /** - * Fetches recently created pages. See {@link #recentChanges(Wiki.RequestHelper, - * String)} for full documentation. Equivalent to [[Special:Newpages]]. + * Fetches the amount most recently created pages in the main + * namespace. WARNING: The + * recentchanges table stores new pages for a finite period of time; + * it is not possible to retrieve pages created before then. * - * @param helper a {@link Wiki.RequestHelper} (optional, use null to not - * provide any optional parameters + * @param amount the number of pages to fetch (overrides global query + * limits) + * @return the revisions that created the pages satisfying the requirements + * above + * @throws IOException if a network error occurs + * @since 0.20 + */ + public Revision[] newPages(int amount) throws IOException + { + return recentChanges(amount, 0, true, MAIN_NAMESPACE); + } + + /** + * Fetches the amount most recently created pages in the main + * namespace subject to the specified constraints. WARNING: The recentchanges + * table stores new pages for a finite period of + * time; it is not possible to retrieve pages created before then. + * Equivalent to [[Special:Newpages]]. + * + * @param rcoptions a bitmask of {@link #HIDE_ANON} etc that dictate which + * pages we return (e.g. to exclude patrolled pages set rcoptions = HIDE_PATROLLED). + * @param amount the amount of new pages to get (overrides global query + * limits) + * @param ns a list of namespaces to filter by, empty = all namespaces. * @return the revisions that created the pages satisfying the requirements * above * @throws IOException if a network error occurs - * @since 0.35 + * @since 0.20 */ - public List newPages(Wiki.RequestHelper helper) throws IOException + public Revision[] newPages(int amount, int rcoptions, int... ns) throws IOException { - return recentChanges(helper, "new"); + return recentChanges(amount, rcoptions, true, ns); } /** - * Fetches recent edits to this wiki. See {@link - * #recentChanges(Wiki.RequestHelper, String)} for full documentation. + * Fetches the amount most recent changes in the main namespace. + * WARNING: The + * recentchanges table stores new pages for a finite period of + * time; it is not possible to retrieve pages created before then. * Equivalent to [[Special:Recentchanges]]. + *

+ * Note: Log entries in recent changes have a revid of 0! * - * @param helper a {@link Wiki.RequestHelper} (optional, use null to not - * provide any optional parameters + * @param amount the number of entries to return (overrides global query + * limits) * @return the recent changes that satisfy these criteria * @throws IOException if a network error occurs * @since 0.23 */ - public List recentChanges(Wiki.RequestHelper helper) throws IOException + public Revision[] recentChanges(int amount) throws IOException { - return recentChanges(helper, null); + return recentChanges(amount, 0, false, MAIN_NAMESPACE); } /** - * Fetches recent changes to this wiki. WARNING: The recentchanges - * table stores edits for a - * finite period of time; it is not possible to retrieve pages created - * before then. Equivalent to [[Special:Recentchanges]]. - * + * Fetches the amount most recent changes in the specified + * namespace. WARNING: The recent changes table only stores new pages for + * about a month. It is not possible to retrieve changes before then. + * Equivalent to [[Special:Recentchanges]]. *

- * Accepted parameters from helper are: - *

    - *
  • {@link Wiki.RequestHelper#withinDateRange(OffsetDateTime, - * OffsetDateTime) date range} - *
  • {@link Wiki.RequestHelper#byUser(String) user} - *
  • {@link Wiki.RequestHelper#notByUser(String) not by user} - *
  • {@link Wiki.RequestHelper#reverse(boolean) reverse} - *
  • {@link Wiki.RequestHelper#inNamespaces(int...) namespaces} - *
  • {@link Wiki.RequestHelper#taggedWith(String) tag} - *
  • {@link Wiki.RequestHelper#filterBy(Map) filter by}: "minor", "bot", - * "anon", "redirect", "patrolled" - *
  • {@link Wiki.RequestHelper#limitedTo(int) local query limit} - *
+ * Note: Log entries in recent changes have a revid of 0! * + * @param amount the number of entries to return (overrides global query + * limits) + * @param ns a list of namespaces to filter by, empty = all namespaces. + * @return the recent changes that satisfy these criteria + * @throws IOException if a network error occurs + * @since 0.23 + */ + public Revision[] recentChanges(int amount, int[] ns) throws IOException + { + return recentChanges(amount, 0, false, ns); + } + + /** + * Fetches the amount most recent changes in the specified + * namespace subject to the specified constraints. WARNING: The recent + * changes table only stores new pages for about a month. It is not + * possible to retrieve changes before then. Equivalent to + * [[Special:Recentchanges]]. *

- * If {@code rctype} is not {@code "edit"} or {@code "new"} then the results - * consist of pseudo-revisions whose data does not correspond to an actual - * on-wiki state. For example: - * - *

    - *
  • {@code rctype == "log"} yields {@code id == 0} and {@code title} is - * the log entry target - *
  • {@code rctype == "external"} yields {@code id} as the most recent - * edit to {@code title}, {@code previous_id == id}, {@code user} is - * the external user making the change, {@code sizediff == 0} and - * {@code comment} describes the external change - *
  • {@code rctype =="categorize"} yields {@code title} as the category - * added or removed and {@code comment} specifies the page added or - * removed to that category - *
+ * Note: Log entries in recent changes have a revid of 0! * - * @param helper a {@link Wiki.RequestHelper} (optional, use null to not - * provide any of the optional parameters described above - * @param rctype null, "edit" (edits only) or "new" (new pages); your - * mileage may vary for other types (log, external, categorize) + * @param amount the number of entries to return (overrides global query + * limits) + * @param ns a list of namespaces to filter by, empty = all namespaces. + * @param rcoptions a bitmask of HIDE_ANON etc that dictate which pages + * we return. * @return the recent changes that satisfy these criteria * @throws IOException if a network error occurs - * @since 0.35 + * @since 0.23 */ - protected List recentChanges(Wiki.RequestHelper helper, String rctype) throws IOException + public Revision[] recentChanges(int amount, int rcoptions, int... ns) throws IOException { - int limit = -1; - Map getparams = new HashMap<>(); - getparams.put("list", "recentchanges"); - getparams.put("rcprop", "title|ids|user|timestamp|flags|comment|parsedcomment|sizes|sha1|tags"); - if (helper != null) - { - helper.setRequestType("rc"); - getparams.putAll(helper.addNamespaceParameter()); - getparams.putAll(helper.addUserParameter()); - getparams.putAll(helper.addExcludeUserParameter()); - getparams.putAll(helper.addDateRangeParameters()); - getparams.putAll(helper.addTagParameter()); - getparams.putAll(helper.addReverseParameter()); - getparams.putAll(helper.addShowParameter()); - limit = helper.limit(); - } - - if (rctype != null) - getparams.put("rctype", rctype); + return recentChanges(amount, rcoptions, false, ns); + } - List revisions = makeListQuery("rc", getparams, null, "recentChanges", limit, + /** + * Fetches the amount most recent changes in the specified + * namespace subject to the specified constraints. WARNING: The recent + * changes table only stores new pages for about a month. It is not + * possible to retrieve changes before then. Equivalent to + * [[Special:Recentchanges]]. + *

+ * Note: Log entries in recent changes have a revid of 0! + * + * @param amount the number of entries to return (overrides global + * query limits) + * @param ns a list of namespaces to filter by, empty = all namespaces. + * @param rcoptions a bitmask of HIDE_ANON etc that dictate which pages + * we return. + * @param newpages show new pages only + * @return the recent changes that satisfy these criteria + * @throws IOException if a network error occurs + * @since 0.23 + */ + protected Revision[] recentChanges(int amount, int rcoptions, boolean newpages, int... ns) throws IOException + { + StringBuilder url = new StringBuilder(query); + url.append("list=recentchanges&rcprop=title%7Cids%7Cuser%7Ctimestamp%7Cflags%7Ccomment%7Csizes%7Csha1"); + constructNamespaceString(url, "rc", ns); + if (newpages) + url.append("&rctype=new"); + // rc options + if (rcoptions > 0) + { + url.append("&rcshow="); + if ((rcoptions & HIDE_ANON) == HIDE_ANON) + url.append("!anon%7C"); + if ((rcoptions & HIDE_SELF) == HIDE_SELF) + url.append("!self%7C"); + if ((rcoptions & HIDE_MINOR) == HIDE_MINOR) + url.append("!minor%7C"); + if ((rcoptions & HIDE_PATROLLED) == HIDE_PATROLLED) + url.append("!patrolled%7C"); + if ((rcoptions & HIDE_BOT) == HIDE_BOT) + url.append("!bot%7C"); + // chop off last | + url.delete(url.length() - 3, url.length()); + } + + int originallimit = getQueryLimit(); + setQueryLimit(amount); + List revisions = queryAPIResult("rc", url, newpages ? "newPages" : "recentChanges", (line, results) -> { // xml form for (int i = line.indexOf(" 0; i = line.indexOf("", i); + int j = line.indexOf("/>", i); results.add(parseRevision(line.substring(i, j), "")); } }); + setQueryLimit(originallimit); - log(Level.INFO, "recentChanges", "Successfully retrieved recent changes (" + revisions.size() + " revisions)"); - return revisions; + int temp = revisions.size(); + log(Level.INFO, "recentChanges", "Successfully retrieved recent changes (" + temp + " revisions)"); + return revisions.toArray(new Revision[temp]); } /** * Fetches all pages that use interwiki links to the specified wiki and the - * page on that wiki that is linked to. For example, {@code - * getInterWikiBacklinks("testwiki")} may return: + * page on that wiki that is linked to. For example, + * getInterWikiBacklinks("testwiki") may return: * * { * { "Spam", "testwiki:Blah" }, @@ -6431,7 +5844,7 @@ protected List recentChanges(Wiki.RequestHelper helper, String rctype) * @throws IOException if a network error occurs * @since 0.23 */ - public List getInterWikiBacklinks(String prefix) throws IOException + public String[][] getInterWikiBacklinks(String prefix) throws IOException { return getInterWikiBacklinks(prefix, "|"); } @@ -6445,8 +5858,8 @@ public List getInterWikiBacklinks(String prefix) throws IOException * *

* Example: If [[Test]] and [[Spam]] both contain the interwiki link - * [[testwiki:Blah]] then {@code getInterWikiBacklinks("testwiki", "Blah");} - * will return (sorted by title) + * [[testwiki:Blah]] then getInterWikiBacklinks("testwiki", "Blah"); + * will return (sorted by title) * * { * { "Spam", "testwiki:Blah" }, @@ -6469,20 +5882,23 @@ public List getInterWikiBacklinks(String prefix) throws IOException * prefix (the MediaWiki API doesn't like this) * @since 0.23 */ - public List getInterWikiBacklinks(String prefix, String title) throws IOException + public String[][] getInterWikiBacklinks(String prefix, String title) throws IOException { // must specify a prefix if (title.equals("|") && prefix.isEmpty()) throw new IllegalArgumentException("Interwiki backlinks: title specified without prefix!"); - Map getparams = new HashMap<>(); - getparams.put("list", "iwbacklinks"); - getparams.put("iwblprefix", prefix); + StringBuilder url = new StringBuilder(query); + url.append("list=iwbacklinks&iwblprefix="); + url.append(prefix); if (!title.equals("|")) - getparams.put("iwbltitle", normalize(title)); - getparams.put("iwblprop", "iwtitle|iwprefix"); + { + url.append("&iwbltitle="); + url.append(title); + } + url.append("&iwblprop=iwtitle%7Ciwprefix"); - List links = makeListQuery("iwbl", getparams, null, "getInterWikiBacklinks", -1, (line, results) -> + List links = queryAPIResult("iwbl", url, "getInterWikiBacklinks", (line, results) -> { // xml form: for (int x = line.indexOf(" 0; x = line.indexOf(" getInterWikiBacklinks(String prefix, String title) throws }); } }); - + log(Level.INFO, "getInterWikiBacklinks", "Successfully retrieved interwiki backlinks (" + links.size() + " interwikis)"); - return links; + return links.toArray(new String[0][0]); } // INNER CLASSES - + /** * Subclass for wiki users. * @since 0.05 */ - public class User implements Comparable + public class User implements Cloneable, Serializable { - private final String username; - private final OffsetDateTime registration; - // user privileges (volatile, changes rarely) - private List rights; - private List groups; - private boolean blocked; - // user preferences (volatile, changes rarely) - private Gender gender; - private boolean emailable; - // volatile, changes often - private int editcount; + private String username; + private String[] rights = null; // cache + private String[] groups = null; // cache /** * Creates a new user object. Does not create a new user on the @@ -6525,27 +5933,11 @@ public class User implements Comparable * be called for anons. * * @param username the username of the user - * @param registration when the user was registered - * @param rights the rights this user has - * @param groups the groups this user belongs to - * @param gender the self-declared {@link Wiki.Gender Gender} of this user. - * @param emailable whether the user can be emailed through [[Special:Emailuser]] - * @param blocked whether this user is blocked - * @param editcount the internal edit count of this user * @since 0.05 */ - protected User(String username, OffsetDateTime registration, List rights, List groups, - Gender gender, boolean emailable, boolean blocked,int editcount) + protected User(String username) { - this.username = Objects.requireNonNull(username); - // can be null per https://phabricator.wikimedia.org/T24097 - this.registration = registration; - this.rights = Objects.requireNonNull(rights); - this.groups = Objects.requireNonNull(groups); - this.gender = gender; - this.emailable = emailable; - this.blocked = blocked; - this.editcount = editcount; + this.username = username; } /** @@ -6553,131 +5945,111 @@ protected User(String username, OffsetDateTime registration, List rights * @return this user's username * @since 0.08 */ - public final String getUsername() + public String getUsername() { return username; } /** - * Gets the date/time at which this user account was created. May be - * {@code null} per - * https://phabricator.wikimedia.org/T24097. + * Gets various properties of this user. Returns: + *

    + *
  • editcount: (int) {@link #countEdits()} the user's edit + * count + *
  • groups: (String[]) the groups the user is in (see + * [[Special:Listgrouprights]]) + *
  • rights: (String[]) the stuff the user can do + *
  • emailable: (Boolean) whether the user can be emailed + * through [[Special:Emailuser]] or emailUser() + *
  • blocked: (Boolean) whether the user is blocked + *
  • gender: (Wiki.Gender) the user's gender + *
  • created: (Calendar) when the user account was created + *
+ * * @return (see above) - * @since 0.35 + * @throws IOException if a network error occurs + * @since 0.24 */ - public final OffsetDateTime getRegistrationDate() + public Map getUserInfo() throws IOException { - return registration; + return Wiki.this.getUserInfo(new String[] { username })[0]; } /** - * Returns {@code true} if the user is allowed to perform the specified - * action(s). Read [[Special:Listgrouprights]] before using this! + * Returns true if the user is allowed to perform the specified action. + * Uses the rights cache. Read [[Special:Listgrouprights]] before using + * this! * @param right a specific action - * @param morerights additional actions to check - * @return whether the user is allowed to execute them + * @return whether the user is allowed to execute it * @since 0.24 + * @throws IOException if a network error occurs */ - public boolean isAllowedTo(String right, String... morerights) - { - List temp = new ArrayList<>(); - temp.add(right); - temp.addAll(List.of(morerights)); - return rights.containsAll(temp); + public boolean isAllowedTo(String right) throws IOException + { + // We can safely assume the user is allowed to { read, edit, create, + // writeapi }. + if (rights == null) + rights = (String[])getUserInfo().get("rights"); + for (String r : rights) + if (r.equals(right)) + return true; + return false; } /** - * Returns {@code true} if the user is a member of the specified group. + * Returns true if the user is a member of the specified group. Uses + * the groups cache. * @param group a specific group * @return whether the user is in it * @since 0.24 + * @throws IOException if a network error occurs */ - public boolean isA(String group) - { - return groups.contains(group); - } - - /** - * Returns the groups the user is a member of. See [[Special:Listgrouprights]]. - * Changes in this list do not propagate to this object or the wiki. - * @return (see above) - * @since 0.35 - */ - public List getGroups() - { - return new ArrayList<>(groups); - } - - /** - * Returns the specific permissions this user has. See [[Special:Listgrouprights]]. - * Changes in this list do not propagate to the object or the wiki. - * @return (see above) - * @since 0.35 - */ - public List getRights() - { - return new ArrayList<>(rights); - } - - /** - * Returns whether this user can be emailed through [[Special:Emailuser]]. - * @return (see above) - * @see #emailUser(Wiki.User, String, String, boolean) - * @since 0.35 - */ - public boolean canBeEmailed() + public boolean isA(String group) throws IOException { - return emailable; + if (groups == null) + groups = (String[])getUserInfo().get("groups"); + for (String g : groups) + if (g.equals(group)) + return true; + return false; } /** - * Returns the self-disclosed {@linkplain Wiki.Gender gender} of this - * user. - * @return (see above) - * @see Wiki.Gender - * @since 0.35 + * Returns a log of the times when the user has been blocked. + * @return records of the occasions when this user has been blocked + * @throws IOException if something goes wrong + * @since 0.08 */ - public Gender getGender() + public LogEntry[] blockLog() throws IOException { - return gender; + return Wiki.this.getLogEntries(Wiki.BLOCK_LOG, null, null, "User:" + username); } /** - * Determines whether this user is blocked at the time of construction. - * If you want a live check, look up the user on the {@linkplain - * #getBlockList list of blocks}. + * Determines whether this user is blocked by looking it up on the IP + * block list. * @return whether this user is blocked + * @throws IOException if we cannot retrieve the IP block list * @since 0.12 */ - public boolean isBlocked() + public boolean isBlocked() throws IOException { - return blocked; + // @revised 0.18 now check for errors after each edit, including blocks + return getIPBlockList(username, null, null).length != 0; } /** - * Fetches the internal edit count for this user at the time of - * construction, which includes all live edits and deleted edits after - * (I think) January 2007. If you want to count live edits only, - * compute the size of {@link User#contribs(int...) User.contribs()}. + * Fetches the internal edit count for this user, which includes all + * live edits and deleted edits after (I think) January 2007. If you + * want to count live edits only, use the slower + * int count = {@link User#contribs(int...) user.contribs()}.length;. * * @return the user's edit count + * @throws IOException if a network error occurs * @since 0.16 */ - public int countEdits() - { - return editcount; - } - - /** - * Returns a log of the times when the user has been blocked. - * @return records of the occasions when this user has been blocked - * @throws IOException if something goes wrong - * @since 0.08 - */ - public List blockLog() throws IOException + public int countEdits() throws IOException { - Wiki.RequestHelper rh = new RequestHelper().byTitle("User:" + username); - return Wiki.this.getLogEntries(Wiki.BLOCK_LOG, null, rh); + return (Integer)getUserInfo().get("editcount"); } /** @@ -6687,430 +6059,243 @@ public List blockLog() throws IOException * @throws IOException if a network error occurs * @since 0.17 */ - public List contribs(int... ns) throws IOException + public Revision[] contribs(int... ns) throws IOException { - Wiki.RequestHelper rh = new RequestHelper().inNamespaces(ns); - return Wiki.this.contribs(username, rh); + return Wiki.this.contribs(username, ns); } - + /** * Returns the list of logged actions performed by this user. * @param logtype what log to get ({@link Wiki#DELETION_LOG} etc.) - * @param action what action to get (e.g. delete, undelete), use + * @param action what action to get (e.g. delete, undelete), use * "" to not specify one * @return (see above) * @throws IOException if a network error occurs * @since 0.33 */ - public List getLogEntries(String logtype, String action) throws IOException + public LogEntry[] getLogEntries(String logtype, String action) throws IOException { - Wiki.RequestHelper rh = new RequestHelper().byUser(username); - return Wiki.this.getLogEntries(logtype, action, rh); + return Wiki.this.getLogEntries(logtype, action, username, null); } /** - * Tests whether this user is equal to another one. - * @param x another object - * @return whether the usernames of the users are equal + * Copies this user object. + * @return the copy + * @throws CloneNotSupportedException if the clone fails + * @since 0.08 */ @Override - public boolean equals(Object x) + public User clone() throws CloneNotSupportedException { - if (!(x instanceof User)) - return false; - User other = (User)x; - return Objects.equals(username, other.username) - && Objects.equals(registration, other.registration); + try + { + return (User)super.clone(); + } + catch (CloneNotSupportedException e) + { + return null; + } } /** - * Returns a hashcode of this user based on the username and - * registration date. - * @return see above + * Tests whether this user is equal to another one. + * @param x another object + * @return whether the usernames of the users are equal + * @since 0.08 */ @Override - public int hashCode() + public boolean equals(Object x) { - return username.hashCode() * 127 + registration.hashCode(); + return x instanceof User && username.equals(((User)x).username); } /** - * Enables sorting of users by their username. - * @param other some other user - * @return less than zero if this user is alphabetically before the - * other, 0 if they are the same and 1 if alphabetically after + * Returns a string representation of this user. + * @return see above + * @since 0.17 */ @Override - public int compareTo(User other) + public String toString() { - return username.compareTo(other.username); + StringBuilder temp = new StringBuilder("User[username="); + temp.append(username); + temp.append("groups="); + temp.append(groups != null ? Arrays.toString(groups) : "unset"); + temp.append("]"); + return temp.toString(); } /** - * Returns a string representation of this user. + * Returns a hashcode of this user. * @return see above + * @since 0.19 */ @Override - public String toString() + public int hashCode() { - return getClass().getName() - + "[username=" + username - + ",registration=" + (registration != null ? registration.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) : "unset") - + ",groups=" + Arrays.toString(groups.toArray()) + "]"; + return username.hashCode() * 2 + 1; } } /** - * A data super class for an event happening on a wiki such as a {@link - * Wiki.Revision} or a {@link Wiki.LogEntry}. - * @since 0.35 + * A wrapper class for an entry in a wiki log, which represents an action + * performed on the wiki. + * + * @see #getLogEntries + * @since 0.08 */ - public abstract class Event implements Comparable + public class LogEntry implements Comparable { - private final long id; + // internal data storage + private long logid = -1; + private String type; + private String action; + private final String reason; + private User user; + private String target; private final OffsetDateTime timestamp; - private final String user; - private final String title; - private final String comment; - private final String parsedcomment; - private List tags; - private boolean commentDeleted = false, userDeleted = false, - contentDeleted = false; + private Object details; + private boolean reasonDeleted = false, userDeleted = false, + targetDeleted = false; /** - * Creates a new Event record. - * @param id the unique ID of the event - * @param timestamp the timestamp at which it occurred - * @param user the user or IP address performing the event - * @param title the title of the page affected - * @param comment the comment left by the user when performing the - * event (e.g. an edit summary) - * @param parsedcomment comment, but parsed into HTML + * Creates a new log entry. WARNING: does not perform the action + * implied. Use Wiki.class methods to achieve this. + * + * @param type the type of log entry, one of {@link #USER_CREATION_LOG}, + * {@link #DELETION_LOG}, {@link #BLOCK_LOG}, etc. + * @param action the type of action that was performed e.g. "delete", + * "unblock", "overwrite", etc. + * @param reason why the action was performed + * @param user the user who performed the action + * @param target the target of the action + * @param timestamp the local time when the action was performed. + * We will convert this back into an OffsetDateTime + * @param details the details of the action (e.g. the new title of + * the page after a move was performed). + * @since 0.08 */ - protected Event(long id, OffsetDateTime timestamp, String user, String title, String comment, String parsedcomment) + protected LogEntry(String type, String action, String reason, User user, + String target, OffsetDateTime timestamp, Object details) { - this.id = id; - this.timestamp = Objects.requireNonNull(timestamp); + this.type = type; + this.action = action; + this.reason = reason; this.user = user; - this.title = title; - this.comment = comment; - // Rewrite parsedcomments to fix useless relative hyperlinks to - // other wiki pages - if (parsedcomment == null) - this.parsedcomment = null; - else - this.parsedcomment = parsedcomment.replace("href=\"/wiki", "href=\"" + protocol + domain + "/wiki"); - } - - /** - * Gets the unique ID of this event. For a {@link Wiki.Revision}, this - * number is referred to as the "oldid" or "revid" and should not be - * confused with "rcid" (which is the ID in the recentchanges table). - * For a {@link Wiki.LogEntry}, this value only makes sense if the - * record was obtained through {@link Wiki#getLogEntries(String, String, - * RequestHelper)} and overloads (other methods return pseudo-LogEntries). - * @return the ID of this revision - */ - public long getID() - { - return id; - } - - /** - * Gets the timestamp of this event. - * @return the timestamp of this event - */ - public OffsetDateTime getTimestamp() - { - return timestamp; - } - - /** - * Returns the user or anon who performed this event. You should pass - * this (if not an IP) to {@link #getUser} to obtain a {@link - * Wiki.User} object. Returns {@code null} if the user was - * RevisionDeleted and you lack the necessary privileges. - * @return the user or anon - */ - public String getUser() - { - return user; - } - - /** - * Sets a boolean flag that the user triggering this event has been - * RevisionDeleted in on-wiki records. - * @param deleted (see above) - * @see #isUserDeleted() - * @see #getUser() - */ - protected void setUserDeleted(boolean deleted) - { - userDeleted = deleted; - } - - /** - * Returns {@code true} if the user triggering this event is - * RevisionDeleted. - * @return (see above) - * @see #getUser() - */ - public boolean isUserDeleted() - { - return userDeleted; + this.target = target; + this.timestamp = timestamp; + this.details = details; } - + /** - * Returns the page affected by this event. May be {@code null} for - * certain types of LogEntry and/or if the LogEntry is RevisionDeleted - * and you don't have the ability to access it. + * Gets the ID of this log entry. Only available if retrieved by + * {@link Wiki#getLogEntries}, otherwise returns -1. * @return (see above) - * @see #isContentDeleted() - */ - public String getTitle() - { - return title; - } - - /** - * Gets the comment for this event in wikitext. If this is a {@link - * Wiki.Revision}, this is the edit summary. If this is a {@link - * Wiki.LogEntry}, this is the reason for the logged action. WARNING: - * returns {@code null} if the reason was RevisionDeleted and you lack - * the necessary privileges. - * @return the comment associated with the event - * @see #getParsedComment() + * @since 0.33 */ - public String getComment() + public long getLogID() { - return comment; + return logid; } /** - * Gets the comment for this event, with limited parsing into HTML. - * Hyperlinks in the returned HTML are rewritten from useless relative - * URLs to full URLs that point to the wiki page in question. Returns - * {@code null} if {@linkplain #isCommentDeleted() the comment was - * RevisionDeleted} and you lack the necessary privileges. - * - *

Warnings: - *

    - *
  • Not available through {@link #getBlockList}. - *
- * - * @return the comment associated with the event, parsed into HTML - * @see #getComment() + * Gets the type of log that this entry is in. + * @return one of {@link Wiki#DELETION_LOG}, {@link Wiki#BLOCK_LOG}, etc. + * @since 0.08 */ - public String getParsedComment() + public String getType() { - return parsedcomment; + return type; } /** - * Sets a boolean flag that the comment associated with this event has - * been RevisionDeleted in on-wiki records. - * @param deleted (see above) - * @see #getComment - * @see #getParsedComment() - * @see #isCommentDeleted() + * Gets a string description of the action performed, for example + * "delete", "protect", "overwrite", ... WARNING: returns null if the + * action was RevisionDeleted. + * @return the type of action performed + * @since 0.08 */ - protected void setCommentDeleted(boolean deleted) + public String getAction() { - commentDeleted = deleted; + return action; } - + /** - * Returns {@code true} if the comment is RevisionDeleted. + * Returns true if the target has been RevisionDeleted (action is hidden + * in the GUI but retrievable by the API). * @return (see above) - * @see #getComment - * @see #getParsedComment() + * @since 0.32 */ - public boolean isCommentDeleted() + public boolean isTargetDeleted() { - return commentDeleted; + return targetDeleted; } /** - * Sets a boolean flag that the content of this event has been - * RevisionDeleted. - * @param deleted (see above) - * @see #isContentDeleted() - */ - protected void setContentDeleted(boolean deleted) - { - contentDeleted = deleted; - } - - /** - * Returns {@code true} if the content of this event has been - * RevisionDeleted. For a {@link Wiki.LogEntry}, this refers to the - * page the logged action affects and the logged action performed (e.g. - * "unblock" or "delete"). - * @return (see above) - */ - public boolean isContentDeleted() - { - return contentDeleted; - } - - /** - * Returns the list of tags attached to this event. Modifying the - * return value does not affect this Revision object or the wiki state. - * @return (see above) - * @see MediaWiki - * documentation - * @since 0.37 + * Gets the reason supplied by the perfoming user when the action + * was performed. WARNING: returns null if the reason was + * RevisionDeleted and one does not have access to the content. + * @return the reason the action was performed + * @since 0.08 */ - public List getTags() + public String getReason() { - return new ArrayList<>(tags); + return reason; } /** - * Sets the list of tags attached to this event. Modifying the supplied - * list does not affect this Revision object or change on-wiki state. - * @param tags a list of change tags - * @see MediaWiki - * documentation - * @since 0.37 - */ - protected void setTags(List tags) - { - this.tags = new ArrayList<>(tags); - } - - /** - * Returns a String representation of this Event. Subclasses only need - * to lop off the trailing "]" and add their own fields when overriding - * this method. + * Returns true if the reason is RevisionDeleted. * @return (see above) + * @since 0.32 */ - @Override - public String toString() + public boolean isReasonDeleted() { - return getClass().getName() - + "[id=" + id - + ",timestamp=" + timestamp.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) - + ",user=\"" + Objects.toString(user, "[DELETED]") + "\"" - + ",userDeleted=" + userDeleted - + ",title=\"" + Objects.toString(title, "[null or deleted]") + "\"" - + ",comment=\"" + Objects.toString(comment, "[DELETED]") + "\"" - + ",commentDeleted=" + commentDeleted - + ",contentDeleted=" + contentDeleted + ']'; + return reasonDeleted; } /** - * Determines whether this Event is equal to some other object. This - * method checks the ID, timestamp, user, title and comment. - * @param other the other object to compare to - * @return whether this instance is equal to that object + * Gets the user object representing who performed the action. + * WARNING: returns null if the user was RevisionDeleted and one does + * not have access to the content. + * @return the user who performed the action. + * @since 0.08 */ - @Override - public boolean equals(Object other) + public User getUser() { - if (!(other instanceof Event)) - return false; - Event event = (Event)other; - return id == event.id - && Objects.equals(timestamp, event.timestamp) - && Objects.equals(user, event.user) - && Objects.equals(title, event.title) - && Objects.equals(comment, event.comment); + return user; } - + /** - * Returns a hash code for this object based on the ID, timestamp, - * user, title and comment. + * Returns true if the user who performed this LogEntry is + * RevisionDeleted. * @return (see above) + * @since 0.32 */ - @Override - public int hashCode() - { - int hc = Long.hashCode(id); - hc = 127 * hc + timestamp.hashCode(); - hc = 127 * hc + Objects.hashCode(user); - hc = 127 * hc + Objects.hashCode(title); - hc = 127 * hc + Objects.hashCode(comment); - return hc; - } - - /** - * Compares this event to another one based on the recentness of their - * timestamps (more recent = positive return value), then - * alphabetically by user. - * @param other the event to compare to - * @return the comparator value, negative if less, positive if greater - */ - @Override - public int compareTo(Wiki.Event other) - { - int result = timestamp.compareTo(other.timestamp); - if (result == 0 && user != null) - result = user.compareTo(other.user); - return result; - } - } - - /** - * A wrapper class for an entry in a wiki log, which represents an action - * performed on the wiki. - * - * @see #getLogEntries - * @since 0.08 - */ - public class LogEntry extends Event - { - private final String type; - private String action; - private Map details; - - /** - * Creates a new log entry. WARNING: does not perform the action - * implied. Use Wiki.class methods to achieve this. - * - * @param id the unique of this log entry - * @param timestamp the local time when the action was performed. - * @param user the user who performed the action - * @param comment why the action was performed - * @param parsedcomment like comment, but parsed into HTML - * @param type the type of log entry, one of {@link #USER_CREATION_LOG}, - * {@link #DELETION_LOG}, {@link #BLOCK_LOG}, etc. - * @param action the type of action that was performed e.g. "delete", - * "unblock", "overwrite", etc. - * @param target the target of the action - * @param details the details of the action (e.g. the new title of - * the page after a move was performed). - * @since 0.08 - */ - protected LogEntry(long id, OffsetDateTime timestamp, String user, String comment, - String parsedcomment, String type, String action, String target, Map details) + public boolean isUserDeleted() { - super(id, timestamp, user, target, comment, parsedcomment); - this.type = Objects.requireNonNull(type); - this.action = action; - this.details = details; + return userDeleted; } /** - * Gets the type of log that this entry is in. - * @return one of {@link Wiki#DELETION_LOG}, {@link Wiki#BLOCK_LOG}, etc. + * Gets the target of the action represented by this log entry. WARNING: + * returns null if the content was RevisionDeleted and one does not + * have access to the content. + * @return the target of this log entry * @since 0.08 */ - public String getType() + public String getTarget() { - return type; + return target; } /** - * Gets a string description of the action performed, for example - * "delete", "protect", "overwrite", ... WARNING: returns null if the - * action was RevisionDeleted. - * @return the type of action performed + * Gets the timestamp of this log entry. + * @return the timestamp of this log entry * @since 0.08 */ - public String getAction() + public OffsetDateTime getTimestamp() { - return action; + return timestamp; } /** @@ -7124,14 +6309,13 @@ public String getAction() * USER_RENAME_LOG * The new username * BLOCK_LOG - * new Object[] { boolean anononly, boolean nocreate, boolean + * new Object[] { boolean anononly, boolean nocreate, boolean * noautoblock, boolean noemail, boolean nousertalk, String duration } * USER_RIGHTS_LOG - * The old ("oldgroups") and new ("newgroups") user rights, - * comma-separated + * The new user rights (String[]) * PROTECTION_LOG * if action == "protect" or "modify" return the protection level - * (int, -2 if unrecognized) if action == "move_prot" return + * (int, -2 if unrecognized) if action == "move_prot" return * the old title, else null * Others or RevisionDeleted * null @@ -7145,7 +6329,7 @@ public String getAction() * @return the details of the log entry * @since 0.08 */ - public Map getDetails() + public Object getDetails() { return details; } @@ -7158,21 +6342,44 @@ public Map getDetails() @Override public String toString() { - StringBuilder s = new StringBuilder(super.toString()); - s.deleteCharAt(s.length() - 1); + StringBuilder s = new StringBuilder("LogEntry[logid="); + s.append(logid); s.append(",type="); s.append(type); s.append(",action="); - s.append(Objects.toString(action, "[DELETED]")); - s.append(",details="); - s.append(details); + s.append(action == null ? "[hidden]" : action); + s.append(",user="); + s.append(user == null ? "[hidden]" : user.getUsername()); + s.append(",timestamp="); + s.append(timestamp.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + s.append(",target="); + s.append(target == null ? "[hidden]" : target); + s.append(",reason=\""); + s.append(reason == null ? "[hidden]" : reason); + s.append("\",details="); + if (details instanceof Object[]) + s.append(Arrays.asList((Object[])details)); // crude formatting hack + else + s.append(details); s.append("]"); return s.toString(); } /** - * Determines whether two LogEntries are equal based on the underlying - * {@linkplain Event#equals(Object) Event}, type and action. + * Compares this log entry to another one based on the recentness + * of their timestamps. + * @param other the log entry to compare + * @return whether this object is equal to + * @since 0.18 + */ + @Override + public int compareTo(Wiki.LogEntry other) + { + return timestamp.compareTo(other.timestamp); + } + + /** + * Determines whether two LogEntries refer to the same event. * @param other some object to compare to * @return (see above) * @since 0.33 @@ -7180,28 +6387,12 @@ public String toString() @Override public boolean equals(Object other) { - if (!super.equals(other)) - return false; if (!(other instanceof LogEntry)) return false; LogEntry le = (LogEntry)other; - return Objects.equals(type, le.type) - && Objects.equals(action, le.action); - } - - /** - * Computes a hashcode for this LogEntry based on the underlying - * {@linkplain Event#hashCode() Event}, type and action. - * @return (see above) - * @since 0.35 - */ - @Override - public int hashCode() - { - int hc = super.hashCode(); - hc = 127 * hc + type.hashCode(); - hc = 127 * hc + Objects.hashCode(action); - return hc; + return type.equals(le.type) && action.equals(le.action) && + user.equals(le.user) && target.equals(le.target) && + reason.equals(le.reason) && timestamp.equals(le.timestamp); } } @@ -7209,39 +6400,46 @@ public int hashCode() * Represents a contribution and/or a revision to a page. * @since 0.17 */ - public class Revision extends Event + public class Revision implements Comparable { - private final boolean minor, bot, rvnew; - private final String sha1; - private long rcid = -1; + private boolean minor, bot, rvnew; + private String summary; + private long revid, rcid = -1; private long previous = 0, next = 0; - private int size = 0, sizediff = 0; + private OffsetDateTime timestamp; + private String user; + private String title; + private String rollbacktoken = null; + private int size = 0; + private int sizediff = 0; + private boolean summaryDeleted = false, userDeleted = false, contentDeleted = false; private boolean pageDeleted = false; /** * Constructs a new Revision object. * @param revid the id of the revision (this is a long since - * {{NUMBEROFEDITS}} on en.wikipedia.org is now (January 2018) ~38% - * of {@code Integer.MAX_VALUE} + * {{NUMBEROFEDITS}} on en.wikipedia.org is now (January 2012) ~25% + * of Integer.MAX_VALUE * @param timestamp when this revision was made - * @param user the user making this revision (may be anonymous) - * @param comment the edit summary - * @param parsedcomment the edit summary, parsed into HTML * @param title the concerned article - * @param sha1 the SHA-1 hash of the revision + * @param summary the edit summary + * @param user the user making this revision (may be anonymous, if not + * use User.getUsername()) * @param minor whether this was a minor edit * @param bot whether this was a bot edit * @param rvnew whether this revision created a new page * @param size the size of the revision * @since 0.17 */ - public Revision(long revid, OffsetDateTime timestamp, String user, String comment, - String parsedcomment, String title, String sha1, boolean minor, boolean bot, - boolean rvnew, int size) + public Revision(long revid, OffsetDateTime timestamp, String title, String summary, String user, + boolean minor, boolean bot, boolean rvnew, int size) { - super(revid, timestamp, user, Objects.requireNonNull(title), comment, parsedcomment); - this.sha1 = sha1; + this.revid = revid; + this.timestamp = timestamp; + this.summary = summary; this.minor = minor; + this.user = user; + this.title = title; this.bot = bot; this.rvnew = rvnew; this.size = size; @@ -7249,7 +6447,7 @@ public Revision(long revid, OffsetDateTime timestamp, String user, String commen /** * Fetches the contents of this revision. - * @return the contents of the appropriate article at timestamp + * @return the contents of the appropriate article at timestamp * @throws IOException if a network error occurs * @throws IllegalArgumentException if page == Special:Log/xxx. * @since 0.17 @@ -7257,125 +6455,176 @@ public Revision(long revid, OffsetDateTime timestamp, String user, String commen public String getText() throws IOException { // logs have no content - if (getID() < 1L) + if (revid < 1L) throw new IllegalArgumentException("Log entries have no valid content!"); // TODO: returning a 404 here when revision content has been deleted // is not a good idea. if (pageDeleted) // FIXME: broken if a page is live, but has deleted revisions { - Map getparams = new HashMap<>(); - getparams.put("action", "query"); - getparams.put("prop", "deletedrevisions"); - getparams.put("drvprop", "content"); - getparams.put("revids", String.valueOf(getID())); - String temp = makeApiCall(getparams, null, "Revision.getText"); - detectUncheckedErrors(temp, null, null); + String url = query + "prop=deletedrevisions&drvprop=content&revids=" + revid; + String temp = fetch(url, "Revision.getText"); int a = temp.indexOf("', a) + 1; + a = temp.indexOf(">", a) + 1; int b = temp.indexOf("", a); // tag not present if revision has no content - log(Level.INFO, "Revision.getText", "Successfully retrieved text of revision " + getID()); + log(Level.INFO, "Revision.getText", "Successfully retrieved text of revision " + revid); return (b < 0) ? "" : temp.substring(a, b); } else - return Wiki.this.getText(null, new long[] { getID() }, -1).get(0); + { + String url = base + encode(title, true) + "&oldid=" + revid + "&action=raw"; + String temp = fetch(url, "Revision.getText"); + log(Level.INFO, "Revision.getText", "Successfully retrieved text of revision " + revid); + return temp; + } } /** * Gets the rendered text of this revision. * @return the rendered contents of the appropriate article at - * timestamp + * timestamp * @throws IOException if a network error occurs * @throws IllegalArgumentException if page == Special:Log/xxx. * @since 0.17 */ public String getRenderedText() throws IOException { - if (getID() < 1L) + // logs have no content + if (revid < 1L) throw new IllegalArgumentException("Log entries have no valid content!"); - Map content = new HashMap<>(); - content.put("revision", this); - return Wiki.this.parse(content, -1, false); + + String temp; + if (pageDeleted) + { + String url = query + "prop=deletedrevisions&drvprop=content&drvparse=1&revids=" + revid; + temp = fetch(url, "Revision.getRenderedText"); + // TODO + } + else + { + String url = base + "&action=render&oldid=" + revid; + temp = fetch(url, "Revision.getRenderedText"); + } + log(Level.INFO, "Revision.getRenderedText", "Successfully retrieved rendered text of revision " + revid); + return decode(temp); } /** - * Returns the SHA-1 hash (base 16, lower case) of the content of this - * revision, or {@code null} if the revision content is RevisionDeleted - * and we cannot access it. - * - *

Warnings: - *

    - *
  • Not available through {@link #watchlist(RequestHelper)} or {@link - * #contribs(List, String, RequestHelper)}. - *
- * + * Returns true if the revision content is RevisionDeleted. * @return (see above) - * @since 0.35 + * @since 0.31 */ - public String getSha1() + public boolean isContentDeleted() { - return sha1; + return contentDeleted; } /** - * Returns a HTML rendered diff table of this revision to other. - * See {@link #diff(Map, Map)} for full documentation. - * + * Returns a HTML rendered diff table; see the table at the example. + * Returns null for page creations, moves, protections and similar + * dummy edits ( + * example). + * * @param other another revision on the same page. * @return the difference between this and the other revision * @throws IOException if a network error occurs - * @throws SecurityException if this or the other revision is - * RevisionDeleted and the user lacks the necessary privileges * @since 0.21 */ public String diff(Revision other) throws IOException { - return Wiki.this.diff(Map.of("revision", this), Map.of("revision", other)); + return diff(other.revid, ""); } /** - * Returns a HTML rendered diff table from this revision to the given - * text. Useful for emulating the "show changes" - * functionality. See the table at the example. - * * @param text some wikitext * @return the difference between this and the the text provided * @throws IOException if a network error occurs - * @throws SecurityException if this or the other revision is - * RevisionDeleted and the user lacks the necessary privileges * @since 0.21 */ public String diff(String text) throws IOException { - return Wiki.this.diff(Map.of("revision", this), Map.of("text", text)); + return diff(0L, text); } /** - * Returns a HTML rendered diff table from this revision to the given - * oldid. See {@link #diff(Map, Map)} for full documentation. - * - * @param oldid the oldid of a revision on the same page. {@link - * Wiki#NEXT_REVISION}, {@link Wiki#PREVIOUS_REVISION} and {@link - * Wiki#CURRENT_REVISION} can be used here for obvious effect. + * Returns a HTML rendered diff table; see the table at the example. + * Returns null for page creations, moves, protections and + * similar dummy edits (example) + * and pairs of revisions where there is no difference. + * + * @param oldid the oldid of a revision on the same page. {@link Wiki#NEXT_REVISION}, + * {@link Wiki#PREVIOUS_REVISION} and {@link Wiki#CURRENT_REVISION} can + * be used here for obvious effect. * @return the difference between this and the other revision * @throws IOException if a network error occurs - * @throws SecurityException if this or the other revision is - * RevisionDeleted and the user lacks the necessary privileges * @since 0.26 */ public String diff(long oldid) throws IOException { - Map from = new HashMap<>(); - from.put("revision", this); - Map to = new HashMap<>(); - to.put("revid", oldid); - return Wiki.this.diff(from, to); + return diff(oldid, ""); + } + + /** + * Fetches a HTML rendered diff table; see the table at the example. + * Returns null for page creations, moves, protections and similar + * dummy edits ( + * example) and pairs of revisions where there is no difference. + * + * @param oldid the id of another revision; (exclusive) or + * @param text some wikitext to compare against + * @return a difference between oldid or text or null if there is no + * diff. + * @throws IOException if a network error occurs + * @since 0.21 + */ + protected String diff(long oldid, String text) throws IOException + { + StringBuilder temp = new StringBuilder(); + if (oldid == NEXT_REVISION) + temp.append("&torelative=next"); + else if (oldid == CURRENT_REVISION) + temp.append("&torelative=cur"); + else if (oldid == PREVIOUS_REVISION) + temp.append("&torelative=prev"); + else if (oldid == 0L) + { + temp.append("&totext="); + temp.append(text); + } + else + { + temp.append("&torev="); + temp.append(oldid); + } + + String line = post(apiUrl + "action=compare&fromrev=" + getRevid(), temp.toString(), "Revision.diff"); + + // strip extraneous information + if (line.contains("")) + { + int a = line.indexOf("", a) + 1; + int b = line.indexOf("", a); + return decode(line.substring(a, b)); + } + else + // tag has no content if there is no diff or the two + // revisions are identical. In particular, the API does not + // distinguish between: + // https://en.wikipedia.org/w/index.php?title=Source_Filmmaker&diff=804972897&oldid=803731343 (no difference) + // https://en.wikipedia.org/w/index.php?title=Dayo_Israel&oldid=738178354&diff=prev (dummy edit) + return null; } /** - * Determines whether this Revision is equal to another based on the - * underlying {@linkplain Event#equals(Object) Event}. + * Determines whether this Revision is equal to another object. * @param o an object * @return whether o is equal to this object * @since 0.17 @@ -7383,29 +6632,20 @@ public String diff(long oldid) throws IOException @Override public boolean equals(Object o) { - // Note to self: don't use SHA-1 until all API calls provide it. - if (!super.equals(o)) - return false; if (!(o instanceof Revision)) return false; - // Revision rev = (Revision)o; - // return Objects.equals(sha1, rev.sha1); - return true; + return revid == ((Revision)o).revid; } /** - * Returns a hash code of this revision based on the underlying - * {@linkplain Event#hashCode() Event}. + * Returns a hash code of this revision. * @return a hash code * @since 0.17 */ @Override public int hashCode() { - // Note to self: don't use SHA-1 until all API calls provide it. - int hc = super.hashCode(); - hc = 127 * hc; - return hc; + return (int)revid * 2 - Wiki.this.hashCode(); } /** @@ -7431,15 +6671,12 @@ public boolean isBot() } /** - * Determines whether this revision created a new page. - * - *

Warnings: - *

    - *
  • Returning {@code true} does not imply this is the bottommost - * revision on the page due to histmerges. - *
  • Not available through {@link #getPageHistory(String, Wiki.RequestHelper)} - *
- * + * Determines whether this revision created a new page.
+ * WARNING: Will return false for all revisions prior to 2007 + * (I think?) -- this is a MediaWiki problem.
+ * WARNING: Returning true does not imply this is the bottommost + * revision on the page due to histmerges.
+ * WARNING: Not accessible through getPageHistory() -- a MW problem. * @return (see above) * @since 0.27 */ @@ -7449,8 +6686,51 @@ public boolean isNew() } /** - * Returns {@code true} if this revision is deleted (not the same as - * RevisionDeleted). + * Returns the edit summary for this revision, or null if the summary + * was RevisionDeleted and you lack the necessary privileges. + * @return the edit summary + * @since 0.17 + */ + public String getSummary() + { + return summary; + } + + /** + * Returns true if the edit summary is RevisionDeleted. + * @return (see above) + * @since 0.30 + */ + public boolean isSummaryDeleted() + { + return summaryDeleted; + } + + /** + * Returns the user or anon who created this revision. You should + * pass this (if not an IP) to getUser(String) to obtain a + * User object. Returns null if the user was RevisionDeleted and you + * lack the necessary privileges. + * @return the user or anon + * @since 0.17 + */ + public String getUser() + { + return user; + } + + /** + * Returns true if the user is RevisionDeleted. + * @return (see above) + * @since 0.30 + */ + public boolean isUserDeleted() + { + return userDeleted; + } + + /** + * Returns true if this revision is deleted (different from revdeleted). * @return (see above) * @since 0.31 */ @@ -7459,6 +6739,37 @@ public boolean isPageDeleted() return pageDeleted; } + /** + * Returns the page to which this revision was made. + * @return the page + * @since 0.17 + */ + public String getPage() + { + return title; + } + + /** + * Returns the oldid of this revision. Don't confuse this with + * rcid + * @return the oldid (long) + * @since 0.17 + */ + public long getRevid() + { + return revid; + } + + /** + * Gets the time that this revision was made. + * @return the timestamp + * @since 0.17 + */ + public OffsetDateTime getTimestamp() + { + return timestamp; + } + /** * Gets the size of this revision in bytes. * @return see above @@ -7470,8 +6781,7 @@ public int getSize() } /** - * Returns the change in page size caused by this revision. Not available - * through getPageHistory or getDeletedHistory. + * Returns the change in page size caused by this revision. * @return see above * @since 0.28 */ @@ -7488,8 +6798,22 @@ public int getSizeDiff() @Override public String toString() { - StringBuilder sb = new StringBuilder(super.toString()); - sb.deleteCharAt(sb.length() - 1); + StringBuilder sb = new StringBuilder("Revision[oldid="); + sb.append(revid); + sb.append(",page=\""); + sb.append(title); + sb.append("\",user="); + sb.append(user == null ? "[hidden]" : user); + sb.append(",userdeleted="); + sb.append(userDeleted); + sb.append(",timestamp="); + sb.append(timestamp.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + sb.append(",summary=\""); + sb.append(summary == null ? "[hidden]" : summary); + sb.append("\",summarydeleted="); + sb.append(summaryDeleted); + sb.append(",contentDeleted="); + sb.append(contentDeleted); sb.append(",minor="); sb.append(minor); sb.append(",bot="); @@ -7502,10 +6826,25 @@ public String toString() sb.append(previous); sb.append(",next="); sb.append(next); + sb.append(",rollbacktoken="); + sb.append(rollbacktoken == null ? "null" : rollbacktoken); sb.append("]"); return sb.toString(); } + /** + * Compares this revision to another revision based on the recentness + * of their timestamps. + * @param other the revision to compare + * @return whether this object is equal to + * @since 0.18 + */ + @Override + public int compareTo(Wiki.Revision other) + { + return timestamp.compareTo(timestamp); + } + /** * Gets the previous revision. * @return the previous revision, or null if this is the first revision @@ -7553,615 +6892,211 @@ public long getRcid() } /** - * Gets a permanent URL to the human-readable version of this Revision. - * This URL uses index.php, not Special:Permanentlink for ease of adding - * other parameters. - * @return (see above) - * @since 0.35 + * Sets a rollback token for this revision. + * @param token a rollback token + * @since 0.24 */ - public String permanentUrl() + public void setRollbackToken(String token) { - return getIndexPhpUrl() + "?oldid=" + getID(); + rollbacktoken = token; } /** - * Reverts this revision using the rollback method. - * - * @throws IOException if a network error occurs - * @throws SecurityException if the user lacks the privileges to rollback - * @throws CredentialExpiredException if cookies have expired - * @throws AccountLockedException if the user is blocked - * @see Wiki#rollback(org.wikipedia.Wiki.Revision) - * @since 0.19 + * Gets the rollback token for this revision. Can be null, and often + * for good reasons: cannot rollback or not top revision. + * @return the rollback token + * @since 0.24 */ - public void rollback() throws IOException, LoginException + public String getRollbackToken() { - Wiki.this.rollback(this, false, ""); + return rollbacktoken; } /** - * Reverts this revision using the rollback method. - * - * @param bot mark this and the reverted revision(s) as bot edits - * @param reason (optional) a custom reason + * Reverts this revision using the rollback method. See + * {@link Wiki#rollback(org.wikipedia.Wiki.Revision)}. + * * @throws IOException if a network error occurs - * @throws SecurityException if the user lacks the privileges to rollback + * @throws CredentialNotFoundException if not logged in or user is not + * an admin * @throws CredentialExpiredException if cookies have expired * @throws AccountLockedException if the user is blocked - * @see Wiki#rollback(org.wikipedia.Wiki.Revision) * @since 0.19 */ - public void rollback(boolean bot, String reason) throws IOException, LoginException - { - Wiki.this.rollback(this, bot, reason); - } - } - - /** - * Vehicle for stuffing standard optional parameters into Wiki queries. - * {@code RequestHelper} objects are reusable. The following example - * fetches articles from the back of the new pages queue on the - * English Wikipedia. - * - * {@code
-     *  Wiki.RequestHelper rh = enWiki.new RequestHelper()
-     *      .inNamespaces(Wiki.MAIN_NAMESPACE)
-     *      .reverse();
-     *  List newpages = enWiki.newPages(rh);
-     *  
} - * - * @since 0.36 - */ - public class RequestHelper - { - private String title; - private String byuser; - private OffsetDateTime earliest, latest; - private int[] localns = new int[0]; - private boolean reverse = false; - private String notbyuser; - private String tag; - private Map options; - private String requestType; - private int limit = -1; - - /** - * Creates a new RequestHelper. - */ - public RequestHelper() - { - } - - /** - * Limits query results to Events occuring on the given title. If a - * query mandates a title parameter (e.g. {@link #getPageHistory(String, - * RequestHelper)}, don't use this. Use the parameter in the query - * method instead. - * @param title a page title - * @return this RequestHelper - */ - public RequestHelper byTitle(String title) - { - this.title = (title == null) ? null : normalize(title); - return this; - } - - /** - * Limits query results to Events triggered by the given user. If a query - * mandates a user parameter (e.g. {@link #contribs(List, String, RequestHelper)}, - * don't use this. Use the parameter in the query method instead. - * @param byuser some username or IP address - * @return this RequestHelper - */ - public RequestHelper byUser(String byuser) - { - this.byuser = (byuser == null) ? null : normalize(byuser); - return this; - } - - /** - * Limit results to be within this date range. - * @param earliest the lower (earliest) date bound, use {@code null} to - * not set one - * @param latest the higher (latest) date bound, use {@code null} to - * not set one - * @throws IllegalArgumentException if {@code earliest.isAfter(latest)} - * @return this RequestHelper - */ - public RequestHelper withinDateRange(OffsetDateTime earliest, OffsetDateTime latest) - { - if (earliest != null && latest != null && earliest.isAfter(latest)) - throw new IllegalArgumentException("Earliest date must be before latest date!"); - this.earliest = earliest; - this.latest = latest; - return this; - } - - /** - * Limits query results to the given namespaces. - * @param ns a list of namespaces - * @return this RequestHelper - */ - public RequestHelper inNamespaces(int... ns) - { - localns = ns; - return this; - } - - /** - * Should we perform this query in reverse order (earliest first). - * @param reverse whether to reverse this query - * @return this RequestHelper - */ - public RequestHelper reverse(boolean reverse) - { - this.reverse = reverse; - return this; - } - - /** - * Limits query results to {@link Event Events} that have been tagged - * with the given tag. - * @param tag a change tag - * @return this RequestHelper - */ - public RequestHelper taggedWith(String tag) - { - this.tag = tag; - return this; - } - - /** - * Limits query results to Events NOT triggered by the given user. - * @param notbyuser some username or IP address to exclude - * @return this RequestHelper - */ - public RequestHelper notByUser(String notbyuser) - { - this.notbyuser = (notbyuser == null) ? null : normalize(notbyuser); - return this; - } - - /** - * Return no more than the given number of results. Overrides {@linkplain - * #setQueryLimit(int) global limits}. Supply a negative integer to use - * global limits. - * @param limit a positive integer - * @return this RequestHelper - */ - public RequestHelper limitedTo(int limit) - { - this.limit = limit; - return this; - } - - /** - * Filters a set of returned results using the given options. Please - * check calling method documentation for supported options. - * - *

- * When filtering revisions, available keys may include "minor", "top", - * "new", "bot", "anon", "redirect", "patrolled" and "unread" for - * vanilla MediaWiki. Extensions may define their own. For instance, - * {@code rcoptions = { minor = true, anon = false, patrolled = false}} - * returns all minor edits from logged in users that aren't patrolled. - * Setting "patrolled" limits results to no older than retention in - * the recentchanges - * table. - * - * @param options the options to filter by - * @return this RequestHelper - */ - public RequestHelper filterBy(Map options) - { - this.options = options; - return this; - } - - /** - * Sets the prefix of API request parameters (the XX in XXlimit, XXdir, - * XXnamespace and so forth). Internal use only. - * @param prefix the prefix to use (must not be null) - */ - protected void setRequestType(String prefix) - { - requestType = Objects.requireNonNull(prefix); - } - - /** - * Returns a HTTP request parameter containing the title to get - * events for, or an empty map if not wanted. - * @return (see above) - */ - protected Map addTitleParameter() - { - if (title != null) - return Map.of(requestType + "title", title); - return Collections.emptyMap(); - } - - /** - * Returns a HTTP request parameter containing the user to filter - * returned events by, or an empty map if not wanted. - * @return (see above) - */ - protected Map addUserParameter() - { - if (byuser != null) - return Map.of(requestType + "user", byuser); - return Collections.emptyMap(); - } - - /** - * Returns a HTTP request parameter containing the dates to start - * and end enumeration, or an empty map if not wanted. - * @return (see above) - */ - protected Map addDateRangeParameters() - { - // https://phabricator.wikimedia.org/T16449 - Map temp = new HashMap<>(); - OffsetDateTime odt = reverse ? earliest : latest; - if (odt != null) - temp.put(requestType + "start", - // https://www.mediawiki.org/wiki/Timestamp - // https://github.com/MER-C/wiki-java/issues/170 - odt.withOffsetSameInstant(ZoneOffset.UTC) - .truncatedTo(ChronoUnit.MICROS) - .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); - odt = reverse ? latest : earliest; - if (odt != null) - temp.put(requestType + "end", - // https://www.mediawiki.org/wiki/Timestamp - // https://github.com/MER-C/wiki-java/issues/170 - odt.withOffsetSameInstant(ZoneOffset.UTC) - .truncatedTo(ChronoUnit.MICROS) - .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); - return temp; - } - - /** - * Returns a HTTP request parameter containing the namespaces to limit - * this query to, or an empty map if not wanted. - * @return (see above) - */ - protected Map addNamespaceParameter() - { - if (localns.length != 0) - return Map.of(requestType + "namespace", constructNamespaceString(localns)); - return Collections.emptyMap(); - } - - /** - * Returns a HTTP request parameter instructing the API to reverse the - * query, or an empty map if not wanted. - * @return (see above) - */ - protected Map addReverseParameter() - { - return Map.of(requestType + "dir", reverse ? "newer" : "older"); - } - - /** - * Returns a HTTP request parameter containing the tag to limit - * returned events to, or an empty map if not wanted. - * @return (see above) - */ - protected Map addTagParameter() - { - if (tag != null) - return Map.of(requestType + "tag", tag); - return Collections.emptyMap(); - } - - /** - * Returns a HTTP request parameter containing the user to exclude - * when returning events, or an empty map if not wanted. - * @return (see above) - */ - protected Map addExcludeUserParameter() - { - if (notbyuser != null) - return Map.of(requestType + "excludeuser", notbyuser); - return Collections.emptyMap(); - } - - /** - * Returns HTTP request parameter(s) containing flags to filter returned - * revisions by, or an empty map if not wanted. - * @return (see above) - */ - protected Map addShowParameter() + public void rollback() throws IOException, LoginException { - Map temp = new HashMap<>(); - if (options != null && !options.isEmpty()) - { - // deal with MW API annoyance for action=query&list=watchlist - see watchlist(rh) - Boolean top = null; - if (requestType.equals("wl")) - { - top = options.remove("top"); - if (Boolean.TRUE.equals(top)) - temp.put("wlallrev", "1"); - } - - StringBuilder sb = new StringBuilder(); - options.forEach((key, value) -> - { - if (Boolean.FALSE.equals(value)) - sb.append('!'); - sb.append(key); - sb.append("|"); - }); - temp.put(requestType + "show", sb.substring(0, sb.length() - 1)); - - if (top != null) // put it back - options.put("top", top); - } - return temp; + Wiki.this.rollback(this, false, ""); } /** - * Returns the number of results the query should be limited to. If not - * present, use {@linkplain #setQueryLimit(int) global limits}. - * @return (see above) + * Reverts this revision using the rollback method. See + * {@link Wiki#rollback(org.wikipedia.Wiki.Revision)}. + * + * @param bot mark this and the reverted revision(s) as bot edits + * @param reason (optional) a custom reason + * @throws IOException if a network error occurs + * @throws CredentialNotFoundException if not logged in or user is not + * an admin + * @throws CredentialExpiredException if cookies have expired + * @throws AccountLockedException if the user is blocked + * @since 0.19 */ - public int limit() + public void rollback(boolean bot, String reason) throws IOException, LoginException { - return limit; + Wiki.this.rollback(this, bot, reason); } } // INTERNALS - /** - * Performs a vectorized action=query&prop=X type API query - * over titles. - * @param queryPrefix the request type prefix (e.g. "pl" for prop=links) - * @param getparams a bunch of parameters to send via HTTP GET - * @param titles a list of titles - * @param caller the name of the calling method - * @param limit fetch no more than this many results - * @param parser a BiConsumer that parses the XML returned by the MediaWiki - * API into things we want, dumping them into the given List - * @return a list of results, where each element corresponds to the element - * at the same index in the input title list - * @since 0.36 - * @throws IOException if a network error occurs - */ - protected List> makeVectorizedQuery(String queryPrefix, Map getparams, - List titles, String caller, int limit, BiConsumer> parser) throws IOException - { - // copy because normalization and redirect resolvers overwrite - List titles2 = new ArrayList<>(titles); - List>> stuff = new ArrayList<>(); - Map postparams = new HashMap<>(); - for (String temp : constructTitleString(titles2)) - { - postparams.put("titles", temp); - stuff.addAll(makeListQuery(queryPrefix, getparams, postparams, caller, -1, (line, results) -> - { - // Split the result into individual listings for each article. - String[] x = line.split(" list = new ArrayList<>(); - parser.accept(x[i], list); - - Map> intermediate = new HashMap<>(); - intermediate.put(parsedtitle, list); - results.add(intermediate); - } - })); - } - - // prepare the return list - List> ret = Stream.generate(() -> new ArrayList()) - .limit(titles2.size()) - .collect(Collectors.toCollection(ArrayList::new)); - // then retrieve the results from the intermediate list of maps, - // ensuring results correspond to inputs - stuff.forEach(map -> - { - String parsedtitle = map.keySet().iterator().next(); - List templates = map.get(parsedtitle); - for (int i = 0; i < titles2.size(); i++) - if (titles2.get(i).equals(parsedtitle)) - ret.get(i).addAll(templates); - }); - return ret; - } - /** * Fetches list-type results from the MediaWiki API. - * - * @param a class describing the parsed API results (e.g. String, + * + * @param a class describing the parsed API results (e.g. String, * LogEntry, Revision) * @param queryPrefix the request type prefix (e.g. "pl" for prop=links) - * @param getparams a bunch of parameters to send via HTTP GET - * @param postparams if not null, send these parameters via POST (see - * {@link #makeApiCall(Map, Map, String) }). + * @param url the query URL, without the limit and XXcontinue parameters * @param caller the name of the calling method - * @param limit fetch no more than this many results * @param parser a BiConsumer that parses the XML returned by the MediaWiki * API into things we want, dumping them into the given List * @return the query results * @throws IOException if a network error occurs - * @throws SecurityException if we don't have the credentials to perform a - * privileged action (mostly avoidable) * @since 0.34 */ - protected List makeListQuery(String queryPrefix, Map getparams, - Map postparams, String caller, int limit, BiConsumer> parser) throws IOException + protected List queryAPIResult(String queryPrefix, StringBuilder url, String caller, + BiConsumer> parser) throws IOException { - if (limit < 0) - limit = querylimit; - getparams.put("action", "query"); List results = new ArrayList<>(1333); - String limitstring = queryPrefix + "limit"; + StringBuilder xxcontinue = new StringBuilder(); + url.append("&"); + url.append(queryPrefix); + url.append("limit="); do { - getparams.put(limitstring, String.valueOf(Math.min(limit - results.size(), max))); - String line = makeApiCall(getparams, postparams, caller); - detectUncheckedErrors(line, null, null); - getparams.keySet().removeIf(param -> param.endsWith("continue")); - + int limit = Math.min(querylimit - results.size(), max); + String tempurl = url.toString() + limit; + String line = fetch(tempurl + xxcontinue.toString(), caller); + xxcontinue.setLength(0); + // Continuation parameter has form: // if (line.contains("", a); - String cont = line.substring(a, b); - for (String contpair : cont.split("\" ")) - { - contpair = " " + contpair.trim(); - String contattr = contpair.substring(0, contpair.indexOf("=\"")); - getparams.put(contattr.trim(), parseAttribute(cont, contattr, 0)); - } + int a = line.indexOf("", a); + String[] temp = line.substring(a, b).split("\""); + xxcontinue.append("&"); + xxcontinue.append(temp[0]); + xxcontinue.append(encode(temp[1], false)); + xxcontinue.append(temp[2].replace(" ", "&")); + xxcontinue.append(encode(temp[3], false)); } - + parser.accept(line, results); } - while (getparams.containsKey("continue") && results.size() < limit); + while (xxcontinue.length() != 0 && results.size() < querylimit); return results; } - - // miscellany + // miscellany + /** - * Convenience method for checking user permissions. - * @param right a user rights - * @param morerights additional user rights - * @throws SecurityException if the permission check fails - * @since 0.37 - */ - protected void checkPermissions(String action, String right, String... morerights) - { - if (user == null) - throw new SecurityException("Cannot " + action + ": not logged in."); - if (!user.isAllowedTo(right, morerights)) - throw new SecurityException("Cannot " + action + ": permission denied."); - } - - /** - * Constructs, sends and handles calls to the MediaWiki API. This is a - * low-level method for making your own, custom API calls. - * - *

- * If postparams is not {@code null} or empty, the request is - * sent using HTTP GET, otherwise it is sent using HTTP POST. A - * {@code byte[]} value in postparams causes the request to be - * sent as a multipart POST. Anything else is converted to String via the - * following means: - * - *

    - *
  • String[] -- {@code String.join("|", arr)} - *
  • StringBuilder -- {@code sb.toString()} - *
  • Number -- {@code num.toString()} - *
  • OffsetDateTime -- {@code date.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)} - *
  • {@code Collection} -- {@code coll.stream() - * .map(item -> convertToString(item)) // using the above rules - * .collect(Collectors.joining("|"))} - *
- * - *

- * All supplied Strings and objects converted to String are automatically - * URLEncoded in UTF-8 if this is a normal POST request. + * A generic URL content fetcher. This is only useful for GET requests, + * which is almost everything that doesn't modify the wiki. Might be + * useful for subclasses. * - *

* Here we also check the database lag and wait if it exceeds * maxlag, see * here for how this works. * - * @param getparams append these parameters to the urlbase - * @param postparams if null, send the request using POST otherwise use GET + * @param url the url to fetch * @param caller the caller of this method - * @return the server response + * @return the content of the fetched URL * @throws IOException if a network error occurs - * @throws SecurityException if we don't have the credentials to perform a - * privileged action (mostly avoidable) * @throws AssertionError if assert=user|bot fails - * @see Multipart/form-data * @since 0.18 */ - public String makeApiCall(Map getparams, Map postparams, String caller) throws IOException + protected String fetch(String url, String caller) throws IOException { - // build the URL - StringBuilder urlbuilder = new StringBuilder(apiUrl + "?"); - getparams.putAll(defaultApiParams); - for (Map.Entry entry : getparams.entrySet()) - { - urlbuilder.append('&'); - urlbuilder.append(entry.getKey()); - urlbuilder.append('='); - urlbuilder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); - } - String url = urlbuilder.toString(); - - // POST stuff - boolean isPOST = (postparams != null && !postparams.isEmpty()); - StringBuilder stringPostBody = new StringBuilder(); - boolean multipart = false; - ArrayList multipartPostBody = new ArrayList<>(); - String boundary = "----------NEXT PART----------"; - if (isPOST) + String temp = null; + int tries = maxtries; + do { - // determine whether this is a multipart post and convert any values - // to String if necessary - for (Map.Entry entry : postparams.entrySet()) + logurl(url, caller); + tries--; + try { - Object value = entry.getValue(); - if (value instanceof byte[]) - multipart = true; - else - entry.setValue(convertToString(value)); - } - - // now we know how we're sending it, construct the post body - if (multipart) - { - byte[] nextpart = ("--" + boundary + "\r\n\"Content-Disposition: form-data; name=\\\"\"") - .getBytes(StandardCharsets.UTF_8); - for (Map.Entry entry : postparams.entrySet()) + // connect + URLConnection connection = makeConnection(url); + connection.setConnectTimeout(CONNECTION_CONNECT_TIMEOUT_MSEC); + connection.setReadTimeout(CONNECTION_READ_TIMEOUT_MSEC); + setCookies(connection); + connection.connect(); + grabCookies(connection); + + // check lag and retry + if (checkLag(connection)) + return fetch(url, caller); + + // get the text + String line; + StringBuilder text = new StringBuilder(100000); + try (BufferedReader in = new BufferedReader(new InputStreamReader( + zipped ? new GZIPInputStream(connection.getInputStream()) : connection.getInputStream(), "UTF-8"))) { - multipartPostBody.add(nextpart); - Object value = entry.getValue(); - multipartPostBody.add((entry.getKey() + "\"\r\n").getBytes(StandardCharsets.UTF_8)); - if (value instanceof String) - multipartPostBody.add(("Content-Type: text/plain; charset=UTF-8\r\n\r\n" + (String)value + "\r\n") - .getBytes(StandardCharsets.UTF_8)); - else if (value instanceof byte[]) + while ((line = in.readLine()) != null) { - multipartPostBody.add("Content-Type: application/octet-stream\r\n\r\n".getBytes(StandardCharsets.UTF_8)); - multipartPostBody.add((byte[])value); - multipartPostBody.add("\r\n".getBytes(StandardCharsets.UTF_8)); + text.append(line); + text.append("\n"); } } - multipartPostBody.add((boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); + temp = text.toString(); } - else + catch (IOException ex) { - // automatically encode Strings sent via normal POST - for (Map.Entry entry : postparams.entrySet()) + if (tries == 0) + throw ex; + try + { + Thread.sleep(10000); + } + catch (InterruptedException ignored) { - stringPostBody.append('&'); - stringPostBody.append(entry.getKey()); - stringPostBody.append('='); - stringPostBody.append(URLEncoder.encode(entry.getValue().toString(), StandardCharsets.UTF_8)); } } } + while (temp == null); + if (temp.contains(" hr = client.send(connection.build(), HttpResponse.BodyHandlers.ofInputStream()); - boolean zipped_ = hr.headers().firstValue("Content-Encoding").orElse("").equals("gzip"); - if (checkLag(hr)) + URLConnection connection = makeConnection(url); + setCookies(connection); + connection.setDoOutput(true); + connection.setConnectTimeout(CONNECTION_CONNECT_TIMEOUT_MSEC); + connection.setReadTimeout(CONNECTION_READ_TIMEOUT_MSEC); + connection.connect(); + + try (OutputStreamWriter out = new OutputStreamWriter(connection.getOutputStream(), "UTF-8")) { - tries++; - throw new HttpRetryException("Database lagged.", 503); + out.write(text); } + // check lag and retry + if (checkLag(connection)) + return post(url, text, caller); + grabCookies(connection); + StringBuilder buffer = new StringBuilder(100000); + String line; try (BufferedReader in = new BufferedReader(new InputStreamReader( - zipped_ ? new GZIPInputStream(hr.body()) : hr.body(), "UTF-8"))) + zipped ? new GZIPInputStream(connection.getInputStream()) : connection.getInputStream(), "UTF-8"))) { - response = in.lines().collect(Collectors.joining("\n")); + while ((line = in.readLine()) != null) + { + buffer.append(line); + buffer.append("\n"); + } } - - // Check for rate limit (though might be a long one e.g. email) - if (response.contains("error code=\"ratelimited\"")) + temp = buffer.toString(); + + // check for recoverable errors + + // rate limit (though might be a long one e.g. email) + if (temp.contains("error code=\"ratelimited\"")) { - // the Retry-After header field is useless here - // see https://phabricator.wikimedia.org/T172293 log(Level.WARNING, caller, "Server-side throttle hit."); - Thread.sleep(10000); throw new HttpRetryException("Action throttled.", 503); } - // Check for database lock - if (response.contains("error code=\"readonly\"")) + // database lock + if (temp.contains("error code=\"readonly\"")) { log(Level.WARNING, caller, "Database locked!"); - Thread.sleep(10000); throw new HttpRetryException("Database locked!", 503); } - - // No need to retry anymore, success or unrecoverable failure. - tries = 0; + return temp; } catch (IOException ex) { - // Exception deliberately ignored until retries are depleted. if (tries == 0) throw ex; - } - catch (InterruptedException ignored) - { + try + { + Thread.sleep(10000); + } + catch (InterruptedException ignored) + { + } } } - while (tries != 0); - - // empty response from server - if (response.isEmpty()) - throw new UnknownError("Received empty response from server!"); - return response; + while (temp.isEmpty()); + throw new AssertionError("Unreachable."); } /** - * Converts HTTP POST parameters to Strings. See {@link #makeApiCall(Map, - * Map, String)} for the description. - * @param param the parameter to convert - * @return that parameter, as a String - * @throws UnsupportedOperationException if param is not a supported data type - * @since 0.35 + * Performs a multi-part HTTP POST. + * @param url the url to post to + * @param params the POST parameters. Supported types: UTF-8 text, byte[]. + * Text and parameter names must NOT be URL encoded. + * @param caller the caller of this method + * @return the server response + * @throws IOException if a network error occurs + * @see #post(java.lang.String, java.lang.String, java.lang.String) + * @see Multipart/form-data + * @since 0.27 */ - private String convertToString(Object param) + protected String multipartPost(String url, Map params, String caller) throws IOException { - // TODO: Replace with type switch in JDK 11/12 - if (param instanceof String) - return (String)param; - else if (param instanceof StringBuilder || param instanceof Number) - return param.toString(); - else if (param instanceof String[]) - return String.join("|", (String[])param); - else if (param instanceof OffsetDateTime) - { - OffsetDateTime date = (OffsetDateTime)param; - // https://www.mediawiki.org/wiki/Timestamp - // https://github.com/MER-C/wiki-java/issues/170 - return date.atZoneSameInstant(ZoneOffset.UTC) - .truncatedTo(ChronoUnit.MICROS) - .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); - } - else if (param instanceof Collection) + String temp = ""; + int tries = maxtries; + do { - Collection coll = (Collection)param; - return coll.stream() - .map(item -> convertToString(item)) - .collect(Collectors.joining("|")); + logurl(url, caller); + tries--; + try + { + URLConnection connection = makeConnection(url); + String boundary = "----------NEXT PART----------"; + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + setCookies(connection); + connection.setDoOutput(true); + connection.setConnectTimeout(CONNECTION_CONNECT_TIMEOUT_MSEC); + connection.setReadTimeout(CONNECTION_READ_TIMEOUT_MSEC); + connection.connect(); + + // write stuff to a local buffer + boundary = "--" + boundary + "\r\n"; + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bout)) + { + out.writeBytes(boundary); + + // write params + for (Map.Entry entry : params.entrySet()) + { + String name = entry.getKey(); + Object value = entry.getValue(); + out.writeBytes("Content-Disposition: form-data; name=\"" + name + "\"\r\n"); + if (value instanceof String) + { + out.writeBytes("Content-Type: text/plain; charset=UTF-8\r\n\r\n"); + out.write(((String)value).getBytes("UTF-8")); + } + else if (value instanceof byte[]) + { + out.writeBytes("Content-Type: application/octet-stream\r\n\r\n"); + out.write((byte[])value); + } + else + throw new UnsupportedOperationException("Unrecognized data type"); + out.writeBytes("\r\n"); + out.writeBytes(boundary); + } + out.writeBytes("--\r\n"); + } + try (OutputStream uout = connection.getOutputStream()) + { + // write the buffer to the URLConnection + uout.write(bout.toByteArray()); + } + + // check lag and retry + if (checkLag(connection)) + return multipartPost(url, params, caller); + + // done, read the response + grabCookies(connection); + String line; + StringBuilder buffer = new StringBuilder(100000); + try (BufferedReader in = new BufferedReader(new InputStreamReader( + zipped ? new GZIPInputStream(connection.getInputStream()) : connection.getInputStream(), "UTF-8"))) + { + while ((line = in.readLine()) != null) + { + buffer.append(line); + buffer.append("\n"); + } + } + temp = buffer.toString(); + + // check for recoverable errors + + // rate limit (though might be a long one e.g. email) + if (temp.contains("error code=\"ratelimited\"")) + { + log(Level.WARNING, caller, "Server-side throttle hit."); + throw new HttpRetryException("Action throttled.", 503); + } + // database lock + if (temp.contains("error code=\"readonly\"")) + { + log(Level.WARNING, caller, "Database locked!"); + throw new HttpRetryException("Database locked!", 503); + } + return temp; + } + catch (IOException ex) + { + if (tries == 0) + throw ex; + try + { + Thread.sleep(10000); + } + catch (InterruptedException ignored) + { + } + } } - else - throw new UnsupportedOperationException("Unrecognized data type"); + while (temp.isEmpty()); + throw new AssertionError("Unreachable."); } - + /** - * Checks for database lag and sleeps if {@code lag < getMaxLag()}. - * @param response the HTTP response received + * Checks for database lag and sleeps if lag > maxlag. + * @param connection the URL connection used in the request * @return true if there was sufficient database lag. - * @throws InterruptedException if any wait was interrupted - * @see #getMaxLag() - * @see - * MediaWiki documentation * @since 0.32 */ - protected synchronized boolean checkLag(HttpResponse response) throws InterruptedException + protected synchronized boolean checkLag(URLConnection connection) { - HttpHeaders hdrs = response.headers(); - long lag = hdrs.firstValueAsLong("X-Database-Lag").orElse(-5); + int lag = connection.getHeaderFieldInt("X-Database-Lag", -5); // X-Database-Lag is the current lag rounded down to the nearest integer. // Thus, we need to retry in case of equality. if (lag >= maxlag) { - long time = hdrs.firstValueAsLong("Retry-After").orElse(10); - logger.log(Level.WARNING, "Current database lag {0} s exceeds maxlag of {1} s, waiting {2} s.", new Object[] { lag, maxlag, time }); - Thread.sleep(time * 1000L); + try + { + int time = connection.getHeaderFieldInt("Retry-After", 10); + logger.log(Level.WARNING, "Current database lag {0} s exceeds maxlag of {1} s, waiting {2} s.", new Object[] { lag, maxlag, time }); + Thread.sleep(time * 1000); + } + catch (InterruptedException ignored) + { + } return true; } return false; } /** - * Sets the HTTPClient used by this instance. Use this to set a proxy, - * SSL parameters and the connection timeout. - * @param builder a HTTP request builder - * @since 0.37 - */ - public void setHttpClient(HttpClient.Builder builder) - { - client = builder.cookieHandler(cookies).build(); - } - - /** - * Creates a new HTTP request. Override to change request properties. + * Creates a new URL connection. Override to change SSL handling, use a + * proxy, etc. * @param url a URL string - * @return a HTTP request builder for that URL + * @return a connection to that URL * @throws IOException if a network error occurs * @since 0.31 */ - protected HttpRequest.Builder makeConnection(String url) throws IOException + protected URLConnection makeConnection(String url) throws IOException { - return HttpRequest.newBuilder(URI.create(url)) - .timeout(Duration.ofMillis(read_timeout_msec)) - .header("User-Agent", useragent) - .header("Accept-encoding", "gzip"); + return new URL(url).openConnection(); } - /** - * Checks for errors from standard read/write requests and throws the - * appropriate unchecked exception. - * - * @param response the response from the server to analyze - * @param errors additional errors to check for where throwing an unchecked - * exception is the desired behavior (function is of MediaWiki error - * message) - * @param warnings additional errors to check for where throwing an exception - * is not required (function is of MediaWiki error message) - * @throws RuntimeException or subclasses depending on the type of errors - * checked for - * @return whether the action was successful - * @since 0.37 - */ - protected boolean detectUncheckedErrors(String response, Map> errors, - Map> warnings) - { - if (response.contains("> uncheckederrors, - Map> info) throws IOException, LoginException + protected void checkErrorsAndUpdateStatus(String line, String caller) throws IOException, LoginException { // perform various status checks every 100 or so edits if (statuscounter > statusinterval) { // purge user rights in case of desysop or loss of other priviliges - user = getUsers(List.of(user.getUsername())).get(0); + user.getUserInfo(); if ((assertion & ASSERT_SYSOP) == ASSERT_SYSOP && !user.isA("sysop")) // assert user.isA("sysop") : "Sysop privileges missing or revoked, or session expired"; throw new AssertionError("Sysop privileges missing or revoked, or session expired"); @@ -8408,36 +7358,35 @@ protected boolean checkErrorsAndUpdateStatus(String line, String caller, else statuscounter++; - if (!line.contains(" constructRevisionString(long[] ids) + protected String[] constructRevisionString(long[] ids) { // sort and remove duplicates per https://mediawiki.org/wiki/API String[] sortedids = Arrays.stream(ids) .distinct() - .filter(id -> id >= 0) .sorted() .mapToObj(String::valueOf) .toArray(String[]::new); - + StringBuilder buffer = new StringBuilder(); - List chunks = new ArrayList<>(); + ArrayList chunks = new ArrayList<>(); for (int i = 0; i < sortedids.length; i++) { buffer.append(sortedids[i]); - if (i == ids.length - 1 || (i % slowmax) == slowmax - 1) + if (i == ids.length - 1 || i == slowmax - 1) { chunks.add(buffer.toString()); buffer.setLength(0); } else - buffer.append('|'); + buffer.append("%7C"); } - return chunks; + return chunks.toArray(new String[chunks.size()]); } /** * Cuts up a list of titles into batches for prop=X&titles=Y type queries. + * @param lengthBaseUrl the length of the url when no titles are present * @param titles a list of titles. + * @param limit whether to apply the maximum URL size * @return the titles ready for insertion into a URL + * @throws IOException if a network error occurs * @since 0.29 */ - protected List constructTitleString(List titles) + protected String[] constructTitleString(int lengthBaseUrl, String[] titles, boolean limit) throws IOException { - // sort and remove duplicates - List titles_unique = titles.stream().sorted().distinct().collect(Collectors.toList()); - + // sort and remove duplicates per https://mediawiki.org/wiki/API + Set set = new TreeSet<>(); + for (String title : titles) + set.add(encode(title, true)); + String[] titlesEnc = set.toArray(new String[set.size()]); + // actually construct the string + String titleStringToken = encode("|", false); ArrayList ret = new ArrayList<>(); - for (int i = 0; i < titles_unique.size() / slowmax + 1; i++) + StringBuilder buffer = new StringBuilder(); + buffer.append(titlesEnc[0]); + int num = 1; + for (int i = num; i < titlesEnc.length; i++) { - ret.add(String.join("|", - titles_unique.subList(i * slowmax, Math.min(titles_unique.size(), (i + 1) * slowmax)))); + if (num < slowmax && + buffer.length() + titleStringToken.length() + titlesEnc[i].length() < URL_LENGTH_LIMIT - lengthBaseUrl) + { + buffer.append(titleStringToken); + } + else + { + ret.add(buffer.toString()); + buffer.setLength(0); + num = 0; + } + buffer.append(titlesEnc[i]); + ++num; } - return ret; + ret.add(buffer.toString()); + return ret.toArray(new String[ret.size()]); + } + + /** + * UTF-8 encode the String with URLEncoder after optional normalization; + * Usually, normalize should be set to true when a title or name String is + * passed in as an argument of a method. + * + * @param text the text to encode + * @param normalize if the text should be normalized first + * @return the encoded text + * @throws IOException if a network error occurs during initialization of the namespaces + */ + private String encode(String text, boolean normalize) throws IOException + { + final String encoding = "UTF-8"; + if (normalize) + text = normalize(text); + return URLEncoder.encode(text, encoding); } /** * Convenience method for normalizing MediaWiki titles. (Converts all * underscores to spaces, localizes namespace names, fixes case of first - * char and does some other unicode fixes). Beware that this will not - * produce the same results as server-side normalization in a few corner - * cases, most notably: HTML entities and gender distinction in the user - * namespace prefix. + * char and does some other unicode fixes). * @param s the string to normalize * @return the normalized string * @throws IllegalArgumentException if the title is invalid - * @throws UncheckedIOException if the namespace cache has not been - * populated, and a network error occurs when populating it + * @throws IOException if a network error occurs * @since 0.27 */ - public String normalize(String s) + public String normalize(String s) throws IOException { - // remove section names - if (s.contains("#")) - s = s.substring(0, s.indexOf("#")); // remove leading colon if (s.startsWith(":")) s = s.substring(1); - s = s.replace('_', ' ').trim(); if (s.isEmpty()) - throw new IllegalArgumentException("Empty or whitespace only title."); + return s; int ns = namespace(s); // localize namespace names if (ns != MAIN_NAMESPACE) { - int colon = s.indexOf(':'); + int colon = s.indexOf(":"); s = namespaceIdentifier(ns) + s.substring(colon); } char[] temp = s.toCharArray(); @@ -8600,7 +7583,7 @@ public String normalize(String s) { switch (temp[i]) { - // illegal characters + // illegal characters case '{': case '}': case '<': @@ -8609,10 +7592,13 @@ public String normalize(String s) case ']': case '|': throw new IllegalArgumentException(s + " is an illegal title"); + case '_': + temp[i] = ' '; + break; } } // https://mediawiki.org/wiki/Unicode_normalization_considerations - String temp2 = new String(temp).replaceAll("\\s+", " "); + String temp2 = new String(temp).trim().replaceAll("\\s+", " "); return Normalizer.normalize(temp2, Normalizer.Form.NFC); } @@ -8621,7 +7607,7 @@ public String normalize(String s) * and other write actions. * @since 0.30 */ - protected synchronized void throttle() + private synchronized void throttle() { try { @@ -8641,7 +7627,7 @@ protected synchronized void throttle() * Checks whether the currently logged on user has sufficient rights to * edit/move a protected page. * - * @param pageinfo the output from {@link #getPageInfo} for an article + * @param pageinfo the output from {@link #getPageInfo(java.lang.String)} * @param action what we are doing * @return whether the user can perform the specified action * @throws IOException if a network error occurs @@ -8658,11 +7644,57 @@ protected boolean checkRights(Map pageinfo, String action) throw if (level.equals(FULL_PROTECTION)) return user.isAllowedTo("editprotected"); } - if (Boolean.TRUE.equals(protectionstate.get("cascade"))) + if ((Boolean)protectionstate.get("cascade") == Boolean.TRUE) // can be null return user.isAllowedTo("editprotected"); return true; } + // cookie methods + + /** + * Sets cookies to an unconnected URLConnection and enables gzip + * compression of returned text. + * @param u an unconnected URLConnection + */ + protected void setCookies(URLConnection u) + { + StringBuilder cookie = new StringBuilder(100); + for (Map.Entry entry : cookies.entrySet()) + { + cookie.append(entry.getKey()); + cookie.append("="); + cookie.append(entry.getValue()); + cookie.append("; "); + } + u.setRequestProperty("Cookie", cookie.toString()); + + // enable gzip compression + if (zipped) + u.setRequestProperty("Accept-encoding", "gzip"); + u.setRequestProperty("User-Agent", useragent); + } + + /** + * Grabs cookies from the URL connection provided. + * @param u an unconnected URLConnection + */ + private void grabCookies(URLConnection u) + { + String headerName; + for (int i = 1; (headerName = u.getHeaderFieldKey(i)) != null; i++) + if (headerName.equalsIgnoreCase("Set-Cookie")) + { + String cookie = u.getHeaderField(i); + cookie = cookie.substring(0, cookie.indexOf(';')); + String name = cookie.substring(0, cookie.indexOf('=')); + String value = cookie.substring(cookie.indexOf('=') + 1, cookie.length()); + // these cookies were pruned, but are still sent for some reason? + // TODO: when these cookies are no longer sent, remove this test + if (!value.equals("deleted")) + cookies.put(name, value); + } + } + // logging methods /** @@ -8687,4 +7719,30 @@ protected void logurl(String url, String method) { logger.logp(Level.INFO, "Wiki", method, "Fetching URL {0}", url); } + + // serialization + + /** + * Writes this wiki to a file. + * @param out an ObjectOutputStream to write to + * @throws IOException if there are local IO problems + * @since 0.10 + */ + private void writeObject(ObjectOutputStream out) throws IOException + { + out.defaultWriteObject(); + } + + /** + * Reads a copy of a wiki from a file. + * @param in an ObjectInputStream to read from + * @throws IOException if there are local IO problems + * @throws ClassNotFoundException if we can't recognize the input + * @since 0.10 + */ + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException + { + in.defaultReadObject(); + statuscounter = statusinterval; // force a status check on next edit + } } diff --git a/src/pattypan/Main.java b/src/pattypan/Main.java index 4724a19..f44ce93 100644 --- a/src/pattypan/Main.java +++ b/src/pattypan/Main.java @@ -84,7 +84,7 @@ public static void main(String[] args) { new String[]{os, Settings.VERSION} ); - Session.WIKI = Wiki.newSession(wiki, scriptPath, protocol); + Session.WIKI = new Wiki(wiki, scriptPath, protocol); launch(args); } } diff --git a/src/pattypan/Session.java b/src/pattypan/Session.java index 073d65e..6ae27a4 100644 --- a/src/pattypan/Session.java +++ b/src/pattypan/Session.java @@ -51,7 +51,7 @@ public final class Session { public static String WIKICODE = ""; public static ArrayList VARIABLES = new ArrayList<>(Arrays.asList("path", "name")); - public static Wiki WIKI = Wiki.newSession("commons.wikimedia.org"); + public static Wiki WIKI = new Wiki("commons.wikimedia.org"); public static ArrayList FILES_TO_UPLOAD = new ArrayList<>(); static {