*  @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 void rollback(boolean bot, String reason) throws IOException, LoginException
        {
            Wiki.this.rollback(this, bot, reason);
        }
    }

    // INTERNALS

    // miscellany

    /**
     *  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
     *  <tt>maxlag</tt>, see <a href="https://mediawiki.org/wiki/Manual:Maxlag_parameter">
     *  here</a> for how this works.
     *
     *  @param url the url to fetch
     *  @param caller the caller of this method
     *  @throws IOException if a network error occurs
     *  @since 0.18
     */
    protected String fetch(String url, String caller) throws IOException
    {
        // connect
        logurl(url, caller);
        URLConnection connection = new URL(url).openConnection();
        connection.setConnectTimeout(CONNECTION_CONNECT_TIMEOUT_MSEC);
        connection.setReadTimeout(CONNECTION_READ_TIMEOUT_MSEC);
        setCookies(connection);
        connection.connect();
        grabCookies(connection);

        // check lag
        int lag = connection.getHeaderFieldInt("X-Database-Lag", -5);
        if (lag > maxlag)
        {
            try
            {
                synchronized(this)
                {
                    int time = connection.getHeaderFieldInt("Retry-After", 10);
                    log(Level.WARNING, "Current database lag " + lag + " s exceeds " + maxlag + " s, waiting " + time + " s.", caller);
                    Thread.sleep(time * 1000);
                }
            }
            catch (InterruptedException ex)
            {
                // nobody cares
            }
            return fetch(url, caller); // retry the request
        }

        // get the text
        BufferedReader in = new BufferedReader(new InputStreamReader(
            zipped ? new GZIPInputStream(connection.getInputStream()) : connection.getInputStream(), "UTF-8"));
        String line;
        StringBuilder text = new StringBuilder(100000);
        while ((line = in.readLine()) != null)
        {
            text.append(line);
            text.append("\n");
        }
        in.close();
        String temp = text.toString();
        if (temp.contains("<error code="))
            // Something *really* bad happened. Most of these are self-explanatory
            // and are indicative of bugs (not necessarily in this framework) or 
            // can be avoided entirely.
            throw new UnknownError("MW API error. Server response was: " + temp);
        return temp;
    }

    /**
     *  Does a text-only HTTP POST.
     *  @param url the url to post to
     *  @param text the text to post
     *  @param caller the caller of this method
     *  @throws IOException if a network error occurs
     *  @return the server response
     *  @see #multipartPost(java.lang.String, java.util.Map, java.lang.String) 
     *  @since 0.24
     */
    protected String post(String url, String text, String caller) throws IOException
    {
        logurl(url, caller);
        URLConnection connection = new URL(url).openConnection();
        setCookies(connection);
        connection.setDoOutput(true);
        connection.setConnectTimeout(CONNECTION_CONNECT_TIMEOUT_MSEC);
        connection.setReadTimeout(CONNECTION_READ_TIMEOUT_MSEC);
        connection.connect();
        OutputStreamWriter out = new OutputStreamWriter(connection.getOutputStream(), "UTF-8");
        out.write(text);
        out.close();
        BufferedReader in = new BufferedReader(new InputStreamReader(
            zipped ? new GZIPInputStream(connection.getInputStream()) : connection.getInputStream(), "UTF-8"));
        grabCookies(connection);
        String line;
        StringBuilder temp = new StringBuilder(100000);
        while ((line = in.readLine()) != null)
        {
            temp.append(line);
            temp.append("\n");
        }
        in.close();
        return temp.toString();
    }
    
    /**
     *  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
     *  @see #post(java.lang.String, java.lang.String, java.lang.String)
     *  @see <a href="http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.2">Multipart/form-data</a>
     *  @since 0.27
     */
    protected String multipartPost(String url, Map<String, ?> params, String caller) throws IOException
    {
        // set up the POST
        logurl(url, caller);
        URLConnection connection = new URL(url).openConnection();
        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();
        boundary = "--" + boundary + "\r\n";
        
        // write stuff to a local buffer
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        DataOutputStream out = new DataOutputStream(bout);
        out.writeBytes(boundary);
        
        // write params
        for (Map.Entry<String, ?> 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");
        out.close();
        // write the buffer to the URLConnection
        OutputStream uout = connection.getOutputStream();
        uout.write(bout.toByteArray());
        uout.close();

        // done, read the response
        BufferedReader in = new BufferedReader(new InputStreamReader(
            zipped ? new GZIPInputStream(connection.getInputStream()) : connection.getInputStream(), "UTF-8"));
        grabCookies(connection);
        String line;
        StringBuilder temp = new StringBuilder(100000);
        while ((line = in.readLine()) != null)
        {
            temp.append(line);
            temp.append("\n");
        }
        in.close();
        return temp.toString();
    }

    /**
     *  Checks for errors from standard read/write requests.
     *  @param line the response from the server to analyze
     *  @param caller what we tried to do
     *  @throws AccountLockedException if the user is blocked
     *  @throws HttpRetryException if the database is locked or action was
     *  throttled and a retry failed
     *  @throws UnknownError in the case of a MediaWiki bug
     *  @since 0.18
     */
    protected void checkErrors(String line, String caller) throws IOException, LoginException
    {
        // empty response from server
        if (line.isEmpty())
            throw new UnknownError("Received empty response from server!");
        // successful
        if (line.contains("result=\"Success\""))
            return;
        // rate limit (automatic retry), though might be a long one (e.g. email)
        if (line.contains("error code=\"ratelimited\""))
        {
            log(Level.WARNING, "Server-side throttle hit.", caller);
            throw new HttpRetryException("Action throttled.", 503);
        }
        // blocked! (note here the \" in blocked is deliberately missing for emailUser()
        if (line.contains("error code=\"blocked") || line.contains("error code=\"autoblocked\""))
        {
            log(Level.SEVERE, "Cannot " + caller + " - user is blocked!.", caller);
            throw new AccountLockedException("Current user is blocked!");
        }
        // cascade protected
        if (line.contains("error code=\"cascadeprotected\""))
        {
            log(Level.WARNING, "Cannot " + caller + " - page is subject to cascading protection.", caller);
            throw new CredentialException("Page is cascade protected");
        }
        // database lock (automatic retry)
        if (line.contains("error code=\"readonly\""))
        {
            log(Level.WARNING, "Database locked!", caller);
            throw new HttpRetryException("Database locked!", 503);
        }
        // unknown error
        if (line.contains("error code=\"unknownerror\""))
            throw new UnknownError("Unknown MediaWiki API error, response was " + line);
        // generic (automatic retry)
        throw new IOException("MediaWiki error, response was " + line);
    }

    /**
     *  Strips entity references like &quot; from the supplied string. This
     *  might be useful for subclasses.
     *  @param in the string to remove URL encoding from
     *  @return that string without URL encoding
     *  @since 0.11
     */
    protected String decode(String in)
    {
        // Remove entity references. Oddly enough, URLDecoder doesn't nuke these.
        in = in.replace("&lt;", "<").replace("&gt;", ">"); // html tags
        in = in.replace("&amp;", "&");
        in = in.replace("&quot;", "\"");
        in = in.replace("&#039;", "'");
        return in;
    }
    
    /**
     *  Parses the next XML attribute with the given name. 
     *  @param xml the xml to search
     *  @param attribute the attribute to search
     *  @param index where to start looking
     *  @return the value of the given XML attribute
     *  @since 0.28
     */
    private String parseAttribute(String xml, String attribute, int index)
    {
        // let's hope the JVM always inlines this
        int a = xml.indexOf(attribute + "=\"", index) + attribute.length() + 2;
        int b = xml.indexOf('\"', a);
        return xml.substring(a, b);
    }
    
    /**
     *  Convenience method for converting a namespace list into String form.
     *  @param sb the url StringBuilder to append to
     *  @param id the request type prefix (e.g. "pl" for prop=links)
     *  @param namespaces the list of namespaces to append
     *  @since 0.27
     */
    protected void constructNamespaceString(StringBuilder sb, String id, int... namespaces)
    {
        int temp = namespaces.length;
        if (temp == 0)
            return;
        sb.append("&");
        sb.append(id);
        sb.append("namespace=");
        for (int i = 0; i < temp - 2; i++)
        {
            sb.append(namespaces[i]);
            sb.append("%7C");
        }
        sb.append(namespaces[temp - 1]);
    }
    
    /**
     *  Convenience method for normalizing MediaWiki titles. (Converts all
     *  spaces to underscores and makes the first letter caps).
     *  @param s the string to normalize
     *  @return the normalized string
     *  @throws IllegalArgumentException if the title is invalid
     *  @since 0.27
     */
    public String normalize(String s)
    {
        if (s.isEmpty())
            return s;
        char[] temp = s.toCharArray();
        temp[0] = Character.toUpperCase(temp[0]);
        for (int i = 0; i < temp.length; i++)
        {
            switch (temp[i])
            {
                // illegal characters
                case '{':
                case '}':
                case '[':
                case ']':
                case '|':
                    throw new IllegalArgumentException(s + " is an illegal title");
                case ' ':
                    temp[i] = '_';
                    break;
            }
        }
        return new String(temp);
    }

    // user rights methods

    /**
     *  Checks whether the currently logged on user has sufficient rights to
     *  edit/move a protected page.
     *
     *  @param pageinfo the output from <tt>getPageInfo()</tt>
     *  @param action what we are doing
     *  @return whether the user can perform the specified action
     *  @throws IOException if a network error occurs
     *  @throws CredentialExpiredException if cookies have expired
     *  @since 0.10
     */
    protected boolean checkRights(HashMap<String, Object> pageinfo, String action) throws IOException, CredentialException
    {
        // Check if we are logged out. Easiest way is via the edit token.
        String token = (String)pageinfo.get("token");
        if (token.equals("\\+") && user != null)
        {
            logger.log(Level.SEVERE, "Session has expired!");
            logout();
            throw new CredentialExpiredException("Cookies have expired.");
        }
        // check protection
        @SuppressWarnings("unchecked")
        HashMap<String, Object> protectionstate = (HashMap<String, Object>)pageinfo.get("protection");
        if (protectionstate.containsKey(action))
        {
            String level = (String)protectionstate.get(action);
            if (level.equals(SEMI_PROTECTION))
                return user.isAllowedTo("autoconfirmed");
            if (level.equals(FULL_PROTECTION))
                return user.isA("sysop");
        }
        return true;
    }

    /**
     *  Performs a status check, including assertions.
     *  @throws AssertionError if any assertions are false
     *  @throws IOException if a network error occurs
     *  @see #setAssertionMode
     *  @since 0.11
     */
    protected void statusCheck() throws IOException
    {
        // @revised 0.18 was assertions(), put some more stuff in here

        // perform various status checks every 100 or so edits
        if (statuscounter > statusinterval)
        {
            // purge user rights in case of desysop or loss of other priviliges
            if (user != null)
                user.getUserInfo();
            // check for new messages
            if ((assertion & ASSERT_NO_MESSAGES) == ASSERT_NO_MESSAGES)
                assert !(hasNewMessages()) : "User has new messages";

            statuscounter = 0;
        }
        else
            statuscounter++;

        // do some more assertions
        if ((assertion & ASSERT_BOT) == ASSERT_BOT)
            assert user.isA("bot") : "Not a bot";
    }

    // 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<String, String> 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
     *  @param map the cookie store
     */
    private void grabCookies(URLConnection u)
    {
        String headerName;
        for (int i = 1; (headerName = u.getHeaderFieldKey(i)) != null; i++)
            if (headerName.equals("Set-Cookie"))
            {
                String cookie = u.getHeaderField(i);
                if(cookie.contains("=deleted")) continue;
                
                cookie = cookie.substring(0, cookie.indexOf(';'));
                String name = cookie.substring(0, cookie.indexOf('='));
                String value = cookie.substring(cookie.indexOf('=') + 1, cookie.length());
                cookies.put(name, value);
            }
    }

    // logging methods

    /**
     *  Logs a successful result.
     *  @param text string the string to log
     *  @param method what we are currently doing
     *  @param level the level to log at
     *  @since 0.06
     */
    protected void log(Level level, String text, String method)
    {
        StringBuilder sb = new StringBuilder(100);
        sb.append('[');
        sb.append(domain);
        sb.append("] ");
        sb.append(text);
        logger.logp(level, "Wiki", method, sb.toString());
    }

    /**
     *  Change the logging level of this object's Logger object.
     *  @param level the new logging level
     *  @since 0.24
     *  @deprecated <tt>logger</tt> is now protected, can call setLevel there
     */
    @Deprecated
    public void setLogLevel(Level level)
    {
    	logger.setLevel(level);
    }

    /**
     *  Logs a url fetch.
     *  @param url the url we are fetching
     *  @param method what we are currently doing
     *  @since 0.08
     */
    protected void logurl(String url, String method)
    {
        logger.logp(Level.INFO, "Wiki", method, "Fetching URL {0}", url);
    }

    // calendar/timestamp methods

    /**
     *  Creates a Calendar object with the current time. Wikimedia wikis use
     *  UTC, override this if your wiki is in another timezone.
     *  @return see above
     *  @since 0.26
     */
    public Calendar makeCalendar()
    {
        return new GregorianCalendar(TimeZone.getTimeZone("UTC"));
    }

    /**
     *  Turns a calendar into a timestamp of the format yyyymmddhhmmss. Might
     *  be useful for subclasses.
     *  @param c the calendar to convert
     *  @return the converted calendar
     *  @see #timestampToCalendar
     *  @since 0.08
     */
    protected String calendarToTimestamp(Calendar c)
    {
        return String.format("%04d%02d%02d%02d%02d%02d", c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
            c.get(Calendar.DAY_OF_MONTH), c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE), c.get(Calendar.SECOND));
    }

    /**
     *  Turns a timestamp of the format yyyymmddhhmmss into a Calendar object.
     *  Might be useful for subclasses.
     *
     *  @param timestamp the timestamp to convert
     *  @return the converted Calendar
     *  @see #calendarToTimestamp
     *  @since 0.08
     */
    protected final Calendar timestampToCalendar(String timestamp)
    {
        Calendar calendar = makeCalendar();
        int year = Integer.parseInt(timestamp.substring(0, 4));
        int month = Integer.parseInt(timestamp.substring(4, 6)) - 1; // January == 0!
        int day = Integer.parseInt(timestamp.substring(6, 8));
        int hour = Integer.parseInt(timestamp.substring(8, 10));
        int minute = Integer.parseInt(timestamp.substring(10, 12));
        int second = Integer.parseInt(timestamp.substring(12, 14));
        calendar.set(year, month, day, hour, minute, second);
        return calendar;
    }

    /**
     *  Converts a timestamp of the form used by the API
     *  (yyyy-mm-ddThh:mm:ssZ) to the form
     *  yyyymmddhhmmss, which can be fed into <tt>timestampToCalendar()</tt>.
     *
     *  @param timestamp the timestamp to convert
     *  @return the converted timestamp
     *  @see #timestampToCalendar
     *  @since 0.12
     */
    protected String convertTimestamp(String timestamp)
    {
        StringBuilder ts = new StringBuilder(timestamp.substring(0, 4));
        ts.append(timestamp.substring(5, 7));
        ts.append(timestamp.substring(8, 10));
        ts.append(timestamp.substring(11, 13));
        ts.append(timestamp.substring(14, 16));
        ts.append(timestamp.substring(17, 19));
        return ts.toString();
    }

    // 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.writeObject(user.getUsername());
        out.writeObject(cookies);
        out.writeInt(throttle);
        out.writeInt(maxlag);
        out.writeInt(assertion);
        out.writeObject(scriptPath);
        out.writeObject(domain);
        out.writeObject(namespaces);
        out.writeInt(statusinterval);
        out.writeObject(useragent);
    }

    /**
     *  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
     */
    @SuppressWarnings("unchecked")
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
    {
        String z = (String)in.readObject();
        user = new Wiki.User(z);
        cookies = (HashMap<String, String>)in.readObject();
        throttle = in.readInt();
        maxlag = in.readInt();
        assertion = in.readInt();
        scriptPath = (String)in.readObject();
        domain = (String)in.readObject();
        namespaces = (HashMap<String, Integer>)in.readObject();
        statusinterval = in.readInt();
        useragent = (String)in.readObject();

        // various other intializations
        initVars();

        // force a status check on next edit
        statuscounter = statusinterval;
    }