#!/usr/bin/env python
'''
Smartsheet attachment sync (by upload) tool.

Synchronizes (by uploading) files to a specified sheet.
This tool is useful if you are using Smartsheet to share files that have their
version history managed separately (such as via git or subversion).

This process is not speedy -- it downloads each attachment in order to compute
a SHA1 hash to determine if the attachment should be updated.  Someday, I
hope that won't be necessary (without just blindly uploading whatever file
in the source directory has the same name as the attachment).

Also produces a report about attachments that are not found in the local 
files and local files that are not attachments.

You must first obtain an access token for the account you will use to
access the sheets.

Author:  Scott Wimer <scott.wimer@smartsheet.com>
'''

import sys
import os
import hashlib
import json
from optparse import OptionParser
import logging

import smartsheetclient

Usage = (
'''usage: %prog [options]
    Synchronize non-empty files in the specified directory with attachments
    to the Sheets specified in the config file.
    Requires the use of an access token.
    The config file is JSON with the following structure:
        {
            "ss_token": "ACCESS TOKEN FOR ACCOUNT",
            "sheets": [
                {
                    "name": "Convenient name, not cannonical",
                    "permalink": "https://app.smartsheet.com/b/home?lx=VmEX..."
                },
                {
                    "name": "Second sheet",
                    "permalink": "https://app.smartsheet.com/b/home?lx=Iipw..."
                }
            ]
        }''')


Log_Level_Map = {'DEBUG': logging.DEBUG, 'INFO': logging.INFO,
        'WARN': logging.WARN, 'ERROR': logging.ERROR, 'FATAL': logging.FATAL}

def main(args):
    use_null_logger = False
    log_level = logging.INFO
    log_file = 'sssyncup.log'

    parser = OptionParser(usage=Usage)
    parser.add_option('-c', '--config-file', dest='conf_file',
            help='Config file to use.', metavar='FILE')
    parser.add_option('-s', '--source-dir', dest='src_dir',
            help='Source directory to use.', metavar='DIR')
    parser.add_option('-D', '--dry-run', dest='dry_run',
            help="Don't actually upload any attachments.",
            default=False, action='store_true', metavar='TRUE/FALSE')
    parser.add_option('-L', '--log-file', dest='log_file',
            help='Log file to append to.', metavar='FILE')
    parser.add_option('' '--log-level', dest='log_level',
            help='Log level to use.',
            choices=['DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'],
            metavar='[DEBUG | INFO | WARN | ERROR | FATAL]')

    conf, opts = parser.parse_args(args)

    if not conf.conf_file:
        print >>sys.stderr, "ERROR: Must specify a config file"
        parser.print_help(sys.stderr)
        return 1
    if not conf.src_dir:
        conf.src_dir = '.'
    if not conf.log_file:
        use_null_logger = True
        log_level = logging.FATAL
    if conf.log_level:
        log_level = Log_Level_Map[conf.log_level]
        use_null_logger = False
    if conf.log_file:
        log_file = conf.log_file
        
    with file(conf.conf_file, 'r') as fh:
        try:
            config = json.load(fh)
        except Exception, e:
            print >>sys.stderr, "Error parsing config file %r" % conf.conf_file
            raise

    if use_null_logger:
        class NullHandler():
            def emit(self, record):
                pass
        logger = logging.getLogger()
        logger.addHandler(NullHandler())
    else:
        logging.basicConfig(filename=log_file, level=log_level)
        logger = logging.getLogger()

    logger.debug("Begin")

    src_files = read_directory(conf.src_dir)
    all_attachments = []
    all_sheets = []
    num_updates = 0

    sc = smartsheetclient.SmartsheetClient(config['ss_token'], logger=logger)
    sc.connect()
    for conf_sheet_info in config['sheets']:
        sheet_info = sc.fetchSheetInfoByPermalink(conf_sheet_info['permalink'],
                use_cache=True)
        sheet = sc.fetchSheetByID(sheet_info.id, attachments=True)
        all_sheets.append(sheet)
        sheet_atts = sheet.allAttachments(include_rows=True)

        print "Found %d attachments on %s" % (len(sheet_atts), sheet.name)

        for att in [a for a in sheet_atts if a.attachmentType == 'FILE']:
            all_attachments.append(att)
            fpath = os.path.join(conf.src_dir, att.name)
            if (os.path.exists(fpath) and os.path.getsize(fpath) != 0):
                print "Comparing:  %r from %s  vs.  file %s" % (
                        att.name, att.sourceInfo, fpath)
                if should_upload(sc, att, fpath):
                    print "    Should upload %s => %s" % (fpath, att.name)
                    if conf.dry_run:
                        print "    [DRY RUN] Skipping upload"
                    else:
                        new_att = upload_new_version(sc, att, fpath)
                        if new_att:
                            print "    Uploaded %s" % fpath
                            num_updates += 1
                        else:
                            logger.error("Upload of %r to Sheet %r failed",
                                    fpath, att.sheet.name)
                            print "    Upload of %s to Sheet %r failed" % (
                                    fpath, att.sheet.name)
                else:
                    print "    Attachment %r matches file - no upload needed."%(
                            fpath)
    print "Updated %d attachments" % num_updates

    # Report on missing files and missing attachments.
    print
    print "Files in %r not found attached to Sheets (%s):" % (
            conf.src_dir, ', '.join([s.name for s in all_sheets]))
    for fname in src_files:
        found = False
        for sheet in all_sheets:
            att = sheet.getAttachmentByFileName(fname, include_rows=True)
            if att:
                found = True
                break
        if not found:
            print "    %r" % fname

    print "Attachments not found in %r" % conf.src_dir
    for att in all_attachments:
        if att.name not in src_files:
            print "    %r on %r" % (att.name, att.sourceInfo)

    logger.debug("Completed")
    return 0


def should_upload(client, attachment, fpath):
    '''
    Check to see whether or not the attachment should be replaced by fpath.
    Today, this works by downloading the attachment and comparing the
    SHA1 hash of it against the SHA1 hash of the contents of fpath.
    If they are the same, then no upload is needed.
    '''
    di = attachment.download(client)
    att_hash = hash_data(di.data)
    file_hash = hash_file(fpath)
    return att_hash != file_hash


def upload_new_version(client, attachment, fpath):
    '''
    Upload fpath as a new version of the attachment.
    '''
    with file(fpath, 'r') as fh:
        data = fh.read()
        new_att = attachment.uploadNewVersion(data)
        return new_att


def hash_file(path):
    with file(path, 'r') as fh:
        data = fh.read()
        return hash_data(data)


def hash_data(data):
    return hashlib.sha1(data).hexdigest()


def read_directory(dpath):
    '''
    Return a set containing the files in a directory.
    '''
    return set([f for f in os.listdir(dpath) if 
            (os.path.isfile(os.path.join(dpath, f)) and
            (not f.startswith('.')))])


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))

