#! /usr/bin/env python
"""
-------------------------------------------------------------------------------
Copyright (C) 2005, 2006  Sylvain Fourmanoit

Released under the GPL, version 2.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies of the Software and its documentation and acknowledgment shall be
given in the documentation and software packages that this Software was
used.

THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
This is the adesklet's desklet check in script. Have a look at
adesklets documentation for details.

WARNING: This script was not made to be as portable as adesklets_submit,
since it basically only has to run on the maintainer machine.
It was made public because it can also be usefull to locally asserting the
validity of entries before submission. It is the exact script
the maintainer use for checking in incoming submissions.

To run, it needs:

- Python >=2.3, as adesklets
- PIL (Python image library) installed
- tar, supporting the '-j' flag (piping through bzip2). (Not tested
on anything but GNU tar).
- a 'less' able to work on terminal in non-canonical mode (Not tested
on anything but GNU less)
- GNU fileutils for rm with the '-r' GNU extension
- Write access to $HOME/unpack

To run in batch mode (direct fetch of data from a mail server, as the maintainer
do), you will also need:

- Access to a working imap over ssl mail account
- SSL support compiled in Python
- An X connection and xterm in your path if you want to interactively test
the code

This code was not tested on anything, but a Linux 2.6.11 x86 machine with
glibc 2.3.4, running Python 2.4.0 (Final) compiled on gcc 3.4.3.
-------------------------------------------------------------------------------
"""
# --- Libraries --------------------------------------------------------------
import getopt
import imaplib
import email
import email.Utils
import email.MIMEMultipart
import email.MIMEText
import smtplib
import urllib
import os
import shutil
from textwrap import fill
from string import translate, maketrans
from itertools import dropwhile, izip, count

# --- Terminal management ----------------------------------------------------
import termios
import select
import sys

class _stdout:
    def __init__(self, stdout):
        self.stdout=stdout
    def write(self, arg):
        self.stdout.write(arg)
        self.stdout.flush()

def term_setraw():
    if os.isatty(sys.stdin.fileno()):
        mode = termios.tcgetattr(sys.stdin.fileno())
        new = mode
        new[3]=new[3] & ~ (termios.ECHO | termios.ICANON)
        new[6][termios.VMIN] = 1
        new[6][termios.VTIME] = 0
        termios.tcsetattr(sys.stdin.fileno(),termios.TCSAFLUSH,new)
        sys.stdout = _stdout(sys.stdout)
    else:
        mode=None
    return mode

def term_unsetraw(mode):
    if os.isatty(sys.stdin.fileno()):
        mode[3]=mode[3] | termios.ECHO | termios.ICANON
        termios.tcsetattr(sys.stdin.fileno(),termios.TCSAFLUSH,mode)
                             
def getch(delay=None):
    p = select.poll()
    p.register(sys.stdin.fileno(),select.POLLIN)
    p.poll()    
    return sys.stdin.read(1)

def yesno(msg=None):
    if os.isatty(sys.stdin.fileno()):
        if msg: print msg,
        answer = getch().lower()
        print answer
    else:
        answer = 'y'
    return answer=='y'

# --- PIL related functions --------------------------------------------------
import Image
def image_props(filename):
    try:
        im=Image.open(filename)
        result = (filename,
                  (im.format=='JPEG' or im.format=='PNG',im.size))
    except:
        result = None
    return result

def get_ext(filename):
    try:
        im=Image.open(filename)
        ext = {'JPEG':'jpg','PNG':'png'}[im.format]
    except:
        ext = 'tar.bz2'
    return ext

# --- DeskletError exception class -------------------------------------------
class EntryError(Exception):
    def __init__(self, value, desc=None):
        self.value=value
        self.desc=desc
    def __str__(self):
        return str(self.value)

# ----------------------------------------------------------------------------
def send_email(e=None):
    result = False
    if e:
        # Retrieve email info, depending of e class
        #
        if e.__class__ is EntryError:
            if e.desc:
                 info=e.desc['info']
            else:
                return
        else:
             info=e['code']['info']

	subject = 'Re: ' + info['subject']
        
        # Build body
        #
        if e.__class__ == EntryError:
            if e.desc:
                # Build subject
                #
                subject = 'Re: ' + info['subject']
                # Build an error message
                #
                body = '\n'.join(['Hi %s,'% info['author_name'],
                                '',
                                 fill(
                    'your desklet entry sent on %s was rejected. %s'
                    % (info['date'], 'The reason was:'),70),
                                 '',
                                 fill(str(e),70),
                                 '',
                                 fill(
                    'See the documentation; thanks for correcting this. ' +
                    'I am looking forward to include your work ' +
                    'on adesklets site. Regards,',70),
                                 '',
                                 '--',
                                 'Sylvain Fourmanoit'
                                 ])
        else:
            # Build Subject
            #
            subject = 'Re: ' + info['subject'] + ' [ACCEPTED]'
            
            # Build a success message
            #
            body = '\n'.join(['Hi %s,'% info['author_name'],
                              '',
                              fill(
                'your desklet entry sent on %s was sucessfully '
                % info['date'] +
                'scheduled for inclusion on adesklets web site. ' +
                'It should appear there as soon as I ' +
                'will update the site: it will usually occur within ' +
                'the next minutes, but It may be delayed due to ressources ' +
                'availability problems. Do not hesitate to contact me at ' +
                '<syfou@users.sourceforge.net> if you feel something went wrong.'
                ,70),
                              '',
                              'Thanks again for your contribution! Best regards,',
                              '',
                              '--',
                              'Sylvain Fourmanoit'
                              ])
            
        # Build message out of newly created raw strings
        #
        msg=email.MIMEMultipart.MIMEMultipart()
        msg['Subject']=subject
        msg['From']='adesklets@mailworks.org'
        msg['To']=info['from']
        msg['Reply-To']='syfou@users.sourceforge.net'
        msg.epilogue=''

        for content in (body, '\n'.join(
            ['# Copy of submitted entry on %s.' % info['date'],
             '# by %s.' % info['from'],
             '#',
             info['body_old']])):
            msg_part=email.MIMEText.MIMEText(content)
            if content!=body:
                msg_part.add_header('Content-Disposition',
                                    'attachment', filename='description.py')
            msg.attach(msg_part)

        # Finally, send message if needed
        #
        print '='*80,'\n'+msg.as_string()+'\n','='*80
        if yesno('Send this message now?'):
            recip=[config['bcc']]
            if e.__class__==EntryError or info['send_confirmation']:
                recip.append(info['author_email'])

            if config.has_key('smtp_host'):
                s = smtplib.SMTP(config['smtp_host'])
                s.sendmail('adesklets@mailworks.org',
                           recip,
                           msg.as_string())
                s.close()
            else:
                print >> file('mail.dump','w'), msg.as_string()
                print 'Message dumped to mail.dump'
            print 'Message sent and marked for deletion:', recip
            result=True
        else:
            print 'Operation cancelled, message not sent.'
    return result
    
# ----------------------------------------------------------------------------
# Validation treatment.
#
def validate(msg):
    """
    Validate a message given as a string, either interactively or not.
    If stdin is not a terminal, interactivity will be turned of, and
    all manual validation will always succeed. Those includes:
    
    - inspection of textual body of email, containing the 'info'
    and 'desklet' dictionaries.
    - validation of README content
    - manual check of the code
    """
    file_list=[]
    tmp=os.path.join(os.getenv('HOME'),'unpack')
    shutil.rmtree(tmp, True)
    try:
        os.mkdir(tmp)
    except OSError, (errstr, errno):
        if errno!=17:
            raise
        
    # Walk through the message, unpacking parts as needed
    #
    for id, part in izip(count(),msg.walk()):
        if part.get_content_maintype()== 'multipart':
            continue
        type=part.get_content_type()
        if type=='text/plain':
            body = reduce(lambda x,y: "%s\n%s" % (x, y),              
                          [ line for line in
                            dropwhile(lambda x:x.split(' ')[0]!='info',
                                      translate(part.__str__(),
                                                maketrans('',''),
                                                '\r').split('\n')) ]) 
        else:
            name=os.path.join(tmp,'part_%d.%s' %
                              (id,
                               {'jpeg':'jpg',
                                'png':'png',
                                'octet-stream':'tar.bz2'}
                               [type.split('/')[1]]))
            file_list.append(name)
            fd=file(name,'w')
            fd.write(part.get_payload(decode=1))
            fd.close()

    # In interactive mode, show the description and ask what to do about it
    #
    if os.isatty(sys.stdin.fileno()):
        print '%(hr)s\n%(body)s\n%(hr)s' % {'hr':'='*80, 'body':body}
    info = { 'author_name' : email.Utils.parseaddr(msg['From'])[0],
             'author_email': email.Utils.parseaddr(msg['From'])[1],
             'from'        : msg['From'],
             'date'        : msg['Date'],
             'subject'     : msg['Subject'],
             'body_old'    : body
             }
    code = {}
    if yesno('Import description ?'):
        try:
            exec body in code
            del code['__builtins__']
        except:
            # No message send back in this case
             if not yesno('Unparsable entry - return address would be ' +
                          "'%s'. Drop email?" % info['author_email']):
                 code['info']=info
             raise EntryError('Entry could not be parsed',
                              (None,code)[len(code)!=0])
        code['info']['date']=info['date']
        code['info']['subject']=info['subject']
        code['info']['body_old']=info['body_old']
        code['info']['from']='%s <%s>' % (code['info']['author_name'],
                                          code['info']['author_email'])
    else:
        if not yesno('Invalid description - return address would be ' +
                     "'%s'. Drop email?" % info['author_email']):
            code['info']=info
        raise EntryError('Entry manually rejected as invalid ' +
                         'before any processing. Contact the maintainer ' +
                         'I you believe this to be unjustified.',
                         (None,code)[len(code)!=0])

    # Now try to download all that can be downloaded
    #
    for id, field in izip(count(id+1),
                          ['thumbnail','screenshot','download']):
        try:
            url = urllib.urlopen(code['desklet'][field])
            name = os.path.join(
                tmp,'part_%d.%s' %
                (id, code['desklet'][field].split('.')[-1]))
            file_list.append(name)
            fd = file(name,'w')
            fd.write(url.read())
            fd.close()
        except AttributeError:
            pass
        except IOError, (str, no):
            if field=='download' and \
                   not code['desklet']['host_on_sourceforge']:
                raise EntryError('You asked your package not to be hosted ' +
                                 'on sourceforge, but it could not be ' +
                                 'retrieved from the provided URL. ' +
                                 'If it was a transient network problem, ' +
                                 'please submit your entry again.', code)

    # We now have all the file info in file_list[0:3]: let's match
    # them to 'thumbnail', 'screenshot' and 'download' by looking
    # at their content
    #
    originals = [ code['desklet'][field] for field in
                  ('thumbnail','screenshot')]
    images    = [image_props(im) for im in file_list[:3]
                 if image_props(im)]
    download  = [name for name in file_list[:3]
    if not name in [im[0] for im in images]]
        
    # =====================================
    # Now, we can perform the tests:
    # =====================================
    # Is there less images than it should?
    #
    if len([field for field in originals if field])\
           != len(images):
        raise EntryError('At least one of your submitted image' +
                         'could not get fetched or properly ' +
                         'decoded (possible corruption?)', code) 
                
    # Is there an image that is not in png or jpeg?
    #
    if len([image for image in images if not image[1][0]]):
        raise EntryError('At least one of your submitted image ' +
                         'is not a valid jpg or png', code)
        
    # Identify the thumbnail and the screenshot based on size
    #
    images_ord=[(image[0],image[1][1][0]*image[1][1][1], image[1][1])
                for image in images]
    images_ord.sort(lambda x,y: x[1]-y[1])
    images_ord= {'thumbnail':  (None,images_ord[0])
                 [code['desklet']['thumbnail']!=None],
                 'screenshot': (None,images_ord[-1])
                 [code['desklet']['screenshot']!=None]}

    # Verify that thumbnail size is in acceptable range
    #
    if images_ord['thumbnail']:
        if not images_ord['thumbnail'][2][0] in range(190,231) or \
               not images_ord['thumbnail'][2][1] in range(35,111):
            raise EntryError('The thumbnail size is out of range: ' +
                             'it is %dx%d ' % images_ord['thumbnail'][2] +
                             'where it should be between ' +
                             '190x35 and 230x110', code)
        
    # Verify the screenshot size is 640x480
    #
    if images_ord['screenshot']:
        if (images_ord['screenshot'][2][0]!=640 or
            images_ord['screenshot'][2][1]!=480):
            raise EntryError('The screenshot size is incorrect: ' +
                             'it is %dx%d ' % images_ord['screenshot'][2] +
                             'where it should be 640x480', code)
        
    # Verify the desklet package...
    # ...Do we have it?
    if len(download)==0:
        raise EntryError('Your desklet package could not be' + 
                         'retrieved. Either you submitted ' +
                         'an unavailable url, or your email ' +
                         'was corrupted. Please recheck things and ' +
                         'resubmit it.', code)

    # Compute the md5 sum of the tarball
    #
    md5 = os.popen('md5sum %s | cut -d" " -f1 2> /dev/null' %
                   download[0]).readlines()[0].strip()  

    # ...Can we extract it?
    #
    if os.system('tar tjf %s &> /dev/null' % download[0])!=0:
        size=os.popen('find %s -printf "%%s\n"' %
                      download[0]).readlines()[0].strip()
        raise EntryError('Tarball could not be extracted - ' +
                         'you should provide a conformant ANSI or V7 ' +
                         'archive, compressed using ' +
                         'the Burrows-Wheeler block sorting ' + 
                         'algorithm (bz2). File was %s bytes, ' % size +
                         'and md5 sum was %s.' % mp5, code)
        
    # ...Is the structure ok?
    #
    dirs=[ dir.strip() for dir in
           os.popen(' | '.join(['tar tjf ' + download[0],
                                'sed "s/\([^\/]*\)\/.*/\\1/"',
                                'sort', 'uniq'])).readlines() ]
    dir='%s-%s' % (code['desklet']['name'],code['desklet']['version'])
    if len(dirs)!=1 or dirs[0] != dir:
        print dirs, dir
        raise EntryError('Tarball internal structure is incorrect. ' +
                         'It should contain one but only one ' +
                         "directory, named '%s'." % dir, code)
    
    # ...Does it have a README in base source directory?
    # If yes, is it valid?
    #
    readme=os.path.join(dir,'README')
    if os.system('tar -O -xjf %s %s &> /dev/null' %
                 (download[0], readme))==0:
        # In case of interactive use, look at README
        if (os.isatty(sys.stdin.fileno())):
            yesno('Piping %s %s README through less...'
                  % (code['desklet']['name'],
                     code['desklet']['version']))
            os.system('tar -O -xjf %s %s | less'
                      % (download[0], readme))
            if not yesno('README complete?'):
                raise EntryError('README does not contains all the ' +
                                     'required information.', code)
            if not yesno('README up to date?'):
                raise EntryError('README does not appear to be ' +
                                 'up to date for version ' +
                                 code['desklet']['version'] + ' of ' +
                                 code['desklet']['name'] + '.', code)
    else:
        raise EntryError('Tarball does not contain the required ' +
                         "'%s' file." % readme, code)

    # Finally, give a chance to perform manual check of the code...
    #
    if (os.isatty(sys.stdin.fileno())):
        if yesno('Extract for manual check of the code (need X) ?'):
            os.system('tar -C %s -xvjf %s' % (tmp, download[0]))
            os.system('export DISPLAY=:0.0 && cd %s && xterm' %
                      os.path.join(tmp,dir))
            if not yesno('code ok?'):
                raise EntryError(
                    'Your entry is still pending. '
                    'While your submission was found valid ' +
                    'by the automated script, the maintainer propably '
                    'experienced some drawback when trying out '
                    'your code. You should receive some non-automated ' +
                    'explanation by email in the upcoming hours.', code)
            
    # Now we know the entry to be ok - sending back the corresponding files
    # on disc.
    #
    return dict([('files',
                  dict([ (image[0],image[1][0]) for image in
                         images_ord.iteritems() if image[1] ] +
                       [ (image[0],None) for image in
                         images_ord.iteritems() if not image[1]] +
                       [('download',download[0]),
                        ('description', os.path.join(tmp,'desc.xml')),
                        ('tmpdir', tmp)])),
                 ('md5', md5), ('code', code),
                 ])

# ----------------------------------------------------------------------------
def rename(desc):
    mapping = [(name[0],desc['files'][name[0]],name[1])
               for name in
               (('thumbnail','%s_thumb' %
                 desc['code']['desklet']['name']),
                ('screenshot','%s_screen' %
                 desc['code']['desklet']['name']),
                ('download','%s-%s' %
                 (desc['code']['desklet']['name'],
                  desc['code']['desklet']['version'])))
               if desc['files'][name[0]]]
    mapping = [(name[0],name[1],'%s.%s' % (name[2],get_ext(name[1]))) \
              for name in mapping] 
    for dummy, name,filename in mapping:
        shutil.move(name, os.path.join(desc['files']['tmpdir'],filename))
    os.system('rm -f %s/part_?.* &> /dev/null' % desc['files']['tmpdir'])
    return (desc, mapping)

# ----------------------------------------------------------------------------
def describe(desc,mapping):
    mapping=dict([('thumbnail', 'default_thumb.jpg'),
                  ('screenshot', 'default_screen.jpg')] +
                 [(name[0], name[2]) for name in mapping])
    fd = file(desc['files']['description'],'w+')
    
    print >> fd, '<desklet name="%s" version="%s"' % \
          (desc['code']['desklet']['name'], desc['code']['desklet']['version'])
    print >> fd, '\tthumbnail="%s"\n\tscreenshot="%s"' % \
          tuple([os.path.join('images',mapping[name]) for name
                 in ('thumbnail','screenshot')])
    if not desc['code']['desklet']['host_on_sourceforge']:
        print >> fd, '\tdownload="%s"' % \
              desc['code']['desklet']['download']
    print >> fd, ' md5="%s">' % desc['md5']
    print >> fd, '<author name="%s" email="%s" />' % \
          (desc['code']['info']['author_name'],
           desc['code']['info']['author_email'])
    print >> fd, desc['code']['desklet']['description'], '\n</desklet>'
    if not desc['code']['desklet']['host_on_sourceforge']:
        os.unlink(os.path.join(desc['files']['tmpdir'],mapping['download']))
    fd.seek(0)
    print '='*80 + '\n' + fd.read() + '='*80
    fd.close()
    if not yesno('Accept this entry (last chance to reject it!) ?'):
        raise EntryError('The entry was manually rejected ' +
                         'for unspecified reasons. The maintainer ' +
                         'should provide you an explanation by email soon.',
                         desc)
    return desc

# ----------------------------------------------------------------------------
# Set terminal
#
mode=term_setraw()

# ----------------------------------------------------------------------------
# Main Loop
#
try:
    # Read in the configuration.from $HOME/.adesklets_checkin
    #
    # This configuration file does not have to exist for non-interactive
    # use, so a desklet developer can basically ignore it when checking
    # configuration produced with adesklets_submit.
    #
    # A working configuration entry would look like:
    #
    # smtp_host   = 'smtp.devil.net',
    # imap_host   = 'mail.devil.net'
    # imap_user   = 'leviathan'
    # imap_passwd = '666_666'
    # bcc         = 'leviathan_backup@devil.net'
    # 
    config_name=os.path.join(os.getenv('HOME'),'.adesklets_checkin')
    config={}
    try:
        f = file(config_name,'r')
        exec f in config
        del config['__builtins__']
        f.close()
    except IOError:
        pass
    
    # In case of interactive use, look up messages
    # on an imap over ssl server
    #
    if os.isatty(sys.stdin.fileno()):
        # Set behavior: look only at unseen (default), or at all messages
        #
        opts, args = getopt.getopt(sys.argv[1:],'',['all'])
        sel = ('UNSEEN','ALL')[len(opts)==1]

        # Connect to imap server
        #
        s=imaplib.IMAP4_SSL(config['imap_host'])
        s.login(config['imap_user'],config['imap_passwd'])
        s.select()

        for num in s.search(None,sel)[1][0].split():
            msg=email.message_from_string(s.fetch(num,'(RFC822)')[1][0][1])
            print '%s\nFrom: %s\nDate: %s' % (msg['Subject'],
                                              msg['From'],
                                              msg['Date'])
            answered=False
            if yesno('Read ?'):
                # Validation, renaming and description
                #
                try:
                    answered=send_email(describe(*rename(validate(msg))))
                except EntryError , e :
                    send_email(e)
            # Deletion
            #
            if answered or yesno('Mark for deletion ?'):
                s.store(num, '+FLAGS.SILENT', '(\DELETED)')
            else:
                # In case of normal mode, preserve the 'unseen' state
                # of email if it was not answered.
                if sel=='UNSEEN':
                    s.store(num, '-FLAGS.SILENT', '(\SEEN)')
                    
        # Mailbox expurge
        #
        if yesno('Expurge messages marked for deletion ?'):
            s.expunge()
        s.logout()
    # In case of non-interactive use, just validate a single message
    # from stdin, and remove eveything from disc right away
    #
    else:
        print 'Validation started. Please wait.'
        msg = email.message_from_string(sys.stdin.read())
        shutil.rmtree(validate(msg)['files']['tmpdir'])
        print fill('Everything seems fine, but keep in mind a few things ' +
        'cannot be verified without human intervention. See documentation ' +
        'for details.',70)
except:
    term_unsetraw(mode)
    raise
term_unsetraw(mode)
