#!/usr/bin/env python
"""
Copyright (c) 2002-2006  Gustavo Niemeyer <gustavo@niemeyer.net>

This program allows you to edit moin (see http://moin.sourceforge.net)
pages with your preferred editor. The default editor is vi. If you want
to use any other, just set the EDITOR environment variable.

To define your moin id used when logging in in a specifc moin, edit a
file named ~/.moin_ids and include lines like "http://moin.url/etc myid".

WARNING: This program expects information to be in a very specific
         format. It will break if this format changes, so there are
         no warranties of working at all. All I can say is that it
         worked for me, at least once. ;-)

Tested moin versions: 0.9, 0.11, 1.0, 1.1, 1.3.5, 1.5, 1.5.1, 1.5.3,
                      1.5.4, 1.5.5, 1.6, 1.6.1, 1.7, 1.8, 1.9, 1.9.1
"""

__author__ = "Gustavo Niemeyer <gustavo@niemeyer.net>"
__version__ = "1.17"
__license__ = "GPL"

import tempfile
import textwrap
import sys, os
import urllib
import shutil
import re
import subprocess
import getpass
import Cookie
import urlparse
try:
    from hashlib import md5
except ImportError:
    from md5 import md5


USAGE = "Usage: editmoin [-t <template page>] <moin page URL>\n"

IDFILENAME = os.path.expanduser("~/.moin_ids")
ALIASFILENAME = os.path.expanduser("~/.moin_aliases")
USERSFILENAME = os.path.expanduser("~/.moin_users")


BODYRE = re.compile('<textarea.*?name="savetext".*?>(.*)</textarea>',
                    re.M|re.DOTALL)
DATESTAMPRE = re.compile('<input.*?name="datestamp".*?value="(.*?)".*?>')
NOTIFYRE = re.compile('<input.*?name="notify".*?value="(.*?)".*?>')
COMMENTRE = re.compile('<input.*?name="comment".*>')
TRIVIALRE = re.compile('<input.*?name="trivial".*?value="(.*?)".*?>')
MESSAGERE1 = re.compile('^</table>(.*?)<a.*?>Clear message</a>',
                        re.M|re.DOTALL)
MESSAGERE2 = re.compile('<div class="message">(.*?)</div>', re.M|re.DOTALL)
MESSAGERE3 = re.compile('<div id="message">\s*<p>(.*?)</p>', re.M|re.DOTALL)
STATUSRE = re.compile('<p class="status">(.*?)</p>', re.M|re.DOTALL)
CANCELRE = re.compile('<input.*?type="submit" name="button_cancel" value="(.*?)">')
EDITORRE = re.compile('<input.*?type="hidden" name="editor" value="text">')
TICKETRE = re.compile('<input.*?type="hidden" name="ticket" value="(.*?)">')
REVRE = re.compile('<input.*?type="hidden" name="rev" value="(.*?)">')
CATEGORYRE = re.compile('<option value="(Category\w+?)">')
SELECTIONRE = re.compile("\(([^)]*)\)\s*([^(]*)")
EXTENDMSG = "Use the Preview button to extend the locking period."
TEXTCHARE = re.compile('<input.*?type="hidden" name="textcha-question" value="(.*?)">', re.M|re.DOTALL)


marker = object()


class Error(Exception): pass


class MoinFile:

    multi_selection = ["notify", "trivial", "add_category"]

    def __init__(self, filename, id, has_moin_session):
        self.filename = filename
        self.id = id
        self.has_moin_session = has_moin_session
        self.data = open(filename).read()
        self.body = self._get_data(BODYRE, "body")

        try:
            self.datestamp = self._get_data(DATESTAMPRE, "datestamp")
        except Error:
            self.datestamp = None

        try:
            self.notify = self._get_data(NOTIFYRE, "notify")
            self.comment = "None"
        except Error:
            self.notify = None
            if COMMENTRE.search(self.data):
                self.comment = "None"
            else:
                self.comment = None

        try:
            self.trivial = self._get_data(TRIVIALRE, "trivial")
        except Error:
            self.trivial = None

        self.categories = self._get_data_findall(CATEGORYRE, "category", [])
        self.add_category = None

        match = STATUSRE.search(self.data)
        if match:
            self.status = strip_html(match.group(1))
        else:
            self.status = None

        self.rev = self._get_data(REVRE, "rev", None)
        self.ticket = self._get_data(TICKETRE, "ticket", None)
        self.question = self._get_data(TEXTCHARE, "question", None)
        self.answer = ""
        self.message = get_message(self.data)

    def _get_data(self, pattern, info, default=marker):
        match = pattern.search(self.data)
        if not match:
            if default is not marker:
                return default
            raise Error, info+" information not found"
        else:
            return match.group(1)

    def _get_data_findall(self, pattern, info, default=marker):
        groups = pattern.findall(self.data)
        if not groups:
            if default is not marker:
                return default
            raise Error, info+" information not found"
        return groups

    def _get_selection(self, str):
        for selected, option in SELECTIONRE.findall(str):
            if selected.strip():
                return option.strip()
        return None

    def _unescape(self, data):
        data = data.replace("&lt;", "<")
        data = data.replace("&gt;", ">")
        data = data.replace("&amp;", "&")
        return data

    def has_cancel(self):
        return (CANCELRE.search(self.data) is not None)

    def has_editor(self):
        return (EDITORRE.search(self.data) is not None)

    def write_raw(self):
        filename = tempfile.mktemp(".moin")
        file = open(filename, "w")
        if self.has_moin_session:
            syntax_version = "1.6"
        else:
            syntax_version = "1.5"
        file.write("@@ Syntax: %s\n" % syntax_version)
        if not self.id:
            file.write("@@ WARNING! You're NOT logged in!\n")
        if self.status is not None:
            text = self.status.replace(EXTENDMSG, "").strip()
            lines = textwrap.wrap(text, 70,
                                  initial_indent="@@ Message: ",
                                  subsequent_indent="@           ")
            for line in lines:
                file.write(line+"\n")
        if self.question is not None:
            file.write("@@ Question: %s\n" % self.question)
            file.write("@@ Answer: %s\n" % self.answer)
        if self.comment is not None:
            file.write("@@ Comment: %s\n" % self.comment)
        if self.trivial is not None:
            file.write("@@ Trivial: ( ) Yes  (x) No\n")
        if self.notify is not None:
            yes, no = (self.notify and ("x", " ") or (" ", "x"))
            file.write("@@ Notify: (%s) Yes  (%s) No\n" % (yes, no))
        if self.categories:
            file.write("@@ Add category: (x) None\n")
            for category in self.categories:
                file.write("@                ( ) %s\n" % category)
        file.write(self._unescape(self.body))
        file.close()
        return filename

    def read_raw(self, filename):
        file = open(filename)
        lines = []
        data = file.readline()
        while data != "\n":
            if data[0] != "@":
                break
            if len(data) < 2:
                pass
            elif data[1] == "@":
                lines.append(data[2:].strip())
            else:
                if not lines:
                    lines.append("")
                lines[-1] += " "
                lines[-1] += data[2:].strip()
            data = file.readline()
        self.body = data+file.read()
        file.close()
        for line in lines:
            sep = line.find(":")   
            if sep != -1:
                attr = line[:sep].lower().replace(' ', '_')
                value = line[sep+1:].strip()
                if attr in self.multi_selection:
                    setattr(self, attr, self._get_selection(value))
                else:
                    setattr(self, attr, value)
 
def get_message(data):
    match = MESSAGERE3.search(data)
    if not match:
        # Check for moin < 1.3.5 (not sure the precise version it changed).
        match = MESSAGERE2.search(data)
    if not match:
        # Check for moin <= 0.9.
        match = MESSAGERE1.search(data)
    if match:
        return strip_html(match.group(1))
    return None 

def strip_html(data):
    data = re.subn("\n", " ", data)[0]
    data = re.subn("<p>|<br>", "\n", data)[0]
    data = re.subn("<.*?>", "", data)[0]
    data = re.subn("Clear data", "", data)[0]
    data = re.subn("[ \t]+", " ", data)[0]
    data = data.strip()
    return data

def get_token_with_url(filename, findurl):
    if os.path.isfile(filename):
        file = open(filename)
        for line in file.readlines():
            line = line.strip()
            if line and line[0] != "#":
                tokens = line.split()
                if len(tokens) > 1:
                    url, token = tokens[:2]
                else:
                    url, token = tokens[0], None
                if findurl.startswith(url):
                    return token
    return None

def get_id(moinurl):
    return get_token_with_url(IDFILENAME, moinurl)

def get_user(moinurl):
    return get_token_with_url(USERSFILENAME, moinurl)

def extract_args(moinurl):
    """Given a URL with a query, strip those that conflict such
    as 'action' and extract those that are equivalent to command line
    arguments such as 'template'.

    >>> extract_args('https://foo.org/Bar')
    ('https://foo.org/Bar', None)

    >>> extract_args('https://foo.org/Baz?action=edit')
    ('https://foo.org/Baz', None)

    >>> extract_args('https://foo.org/Dog?action=edit&template=Template')
    ('https://foo.org/Dog', 'Template')
    """
    parts = urlparse.urlparse(moinurl)
    params = urlparse.parse_qs(parts.query)

    # Pull out the template name
    template = params.get('template', [None])[0]

    # Delete all parameters that might interfere
    for item in "action template".split():
        if item in params:
            del params[item]

    # Turn the parameters back into a query string
    encoded = urllib.urlencode(params)

    next = urlparse.urlunparse((parts.scheme, parts.netloc, parts.path, parts.params, encoded, parts.fragment))
    return next, template

def translate_shortcut(moinurl):
    if "://" in moinurl:
        return moinurl
    if "/" in moinurl:
        shortcut, pathinfo = moinurl.split("/", 1)
    else:
        shortcut, pathinfo = moinurl, ""
    if os.path.isfile(ALIASFILENAME):
        file = open(ALIASFILENAME)
        try:
            for line in file.readlines():
                line = line.strip()
                if line and line[0] != "#":
                    alias, value = line.split(None, 1)
                    if pathinfo:
                        value = "%s/%s" % (value, pathinfo)
                    if shortcut == alias:
                        if "://" in value:
                            return value
                        if "/" in value:
                            shortcut, pathinfo = value.split("/", 1)
                        else:
                            shortcut, pathinfo = value, ""
        finally:
            file.close()
    for filename in (USERSFILENAME, IDFILENAME):
        if os.path.isfile(filename):
            file = open(filename)
            try:
                for line in file.readlines():
                    line = line.strip()
                    if line and line[0] != "#":
                        url = line.split()[0]
                        if shortcut in url:
                            if pathinfo:
                                return "%s/%s" % (url, pathinfo)
                            else:
                                return url
            finally:
                file.close()
    raise Error, "no suitable url found for shortcut '%s'" % shortcut


def get_urlopener(moinurl, cookievalue=None, cookiename="MOIN_SESSION"):
    urlopener = urllib.FancyURLopener()
    proxy = os.environ.get("http_proxy")
    if proxy:
        urlopener.proxies.update({"http": proxy})
    if cookievalue:
        # moinmoin < 1.6
        urlopener.addheader("Cookie", "MOIN_ID=%s" % cookievalue)
        # moinmoin >= 1.6
        urlopener.addheader("Cookie", "%s=%s" % (cookiename, cookievalue))
    return urlopener

def fetchfile(urlopener, url, id, template):
    geturl = url+"?action=edit"
    if template:
        geturl += "&template=" + urllib.quote(template)
    filename, headers = urlopener.retrieve(geturl)
    has_moin_session = "MOIN_SESSION" in headers.get("set-cookie", "")
    return MoinFile(filename, id, has_moin_session)

def editfile(moinfile):
    edited = 0
    if moinfile.message:
        print moinfile.message
    if moinfile.question:
        moinfile.answer = raw_input(moinfile.question + " ")
    filename = moinfile.write_raw()
    editor = os.environ.get("EDITOR", "sensible-editor")
    digest = md5(open(filename).read()).digest()
    subprocess.call("%s %s" % (editor, filename), shell=True)
    if digest != md5(open(filename).read()).digest():
        shutil.copyfile(filename, os.path.expanduser("~/.moin_lastedit"))
        edited = 1
        moinfile.read_raw(filename)
    os.unlink(filename)
    return edited

def sendfile(urlopener, url, moinfile):
    if moinfile.comment is not None:
        comment = "&comment="
        if moinfile.comment.lower() != "none":
            comment += urllib.quote(moinfile.comment)
    else:
        comment = ""
    data = "button_save=1&savetext=%s%s" \
           % (urllib.quote(moinfile.body), comment)
    if moinfile.has_editor():
        data += "&action=edit"      # Moin >= 1.5
    else:
        data += "&action=savepage"  # Moin < 1.5
    if moinfile.datestamp:
        data += "&datestamp=" + moinfile.datestamp
    if moinfile.rev:
        data += "&rev=" + moinfile.rev
    if moinfile.ticket:
        data += "&ticket=" + moinfile.ticket
    if moinfile.notify == "Yes":
        data += "&notify=1"
    if moinfile.trivial == "Yes":
        data += "&trivial=1"
    if moinfile.add_category and moinfile.add_category != "None":
        data += "&category=" + urllib.quote(moinfile.add_category)
    if moinfile.question and moinfile.answer:
        data += "&textcha-question=" + urllib.quote(moinfile.question)
        data += "&textcha-answer=" + urllib.quote(moinfile.answer)
    url = urlopener.open(url, data)
    answer = url.read()
    url.close()
    message = get_message(answer)
    if message is None:
        print answer
        raise Error, "data submitted, but message information not found"
    else:
        print message

def sendcancel(urlopener, url, moinfile):
    if not moinfile.has_cancel():
        return
    data = "button_cancel=Cancel"
    if moinfile.has_editor():
        data += "&action=edit&savetext=dummy"  # Moin >= 1.5
    else:
        data += "&action=savepage"             # Moin < 1.5
    if moinfile.datestamp:
        data += "&datestamp=" + moinfile.datestamp
    if moinfile.rev:
        data += "&rev=" + moinfile.rev
    if moinfile.ticket:
        data += "&ticket=" + moinfile.ticket
    url = urlopener.open(url, data)
    answer = url.read()
    url.close()
    message = get_message(answer)
    if not message:
        print answer
        raise Error, "cancel submitted, but message information not found"
    else:
        print message

def get_session_cookie(user, url):
    password = getpass.getpass("Password for %s: " % user)
    params = urllib.urlencode(dict(login='Login', action='login',
                                   name=user, password=password))
    opener = get_urlopener(url)
    result = opener.open(url, params)
    cookie = Cookie.SimpleCookie()
    cookie.load(result.info()["set-cookie"])
    for morsel in cookie.values():
        sw = morsel.key.startswith
        if sw("MOIN_SESSION") or sw("MOIN_ID"):
            return morsel.key, morsel.value
    raise Error("Couldn't obtain session cookie from server")

def edit(url, template=None, editfile_func=editfile):
    url, url_template = extract_args(url)

    if url_template and not template:
        template = url_template

    url = translate_shortcut(url)
    user = get_user(url)
    if user:
        # Moin >= 1.7
        cookiename, cookievalue = get_session_cookie(user, url)
        urlopener = get_urlopener(url, cookievalue, cookiename)
    else:
        # Moin < 1.7
        cookievalue = get_id(url)
        urlopener = get_urlopener(url, cookievalue)

    moinfile = fetchfile(urlopener, url, cookievalue, template)
    try:
        page_edited = editfile_func(moinfile)
        if page_edited:
            sendfile(urlopener, url, moinfile)
        else:
            sendcancel(urlopener, url, moinfile)
    finally:
        os.unlink(moinfile.filename)
    return page_edited

def main():
    argv = sys.argv[1:]
    template = None
    if len(argv) > 2 and argv[0] == "-t":
        template = argv[1]
        argv = argv[2:]
    if len(argv) != 1 or argv[0] in ("-h", "--help"):
        sys.stderr.write(USAGE)
        sys.exit(1)
    try:
        edit(argv[0], template)
    except (IOError, OSError, Error), e:
        sys.stderr.write("error: %s\n" % str(e))
        sys.exit(1)

if __name__ == "__main__":
    main()

# vim:et:ts=4:sw=4
