# Module gallery
"""This module includes the classes Pic_doc and Gallery, and supporting
functions.  It is used to support the make_gallery module with its
commandline wrapper, and main driver make_gallery. 
"""

import os
import os.path
import shutil
import re
import sys
import fnmatch
import sets
import random

import PIL.Image

from gallery_param import *

# Utility methods (non-class) ------------------------------------------------

def file2str(filename):
    """ Return full file contents as a string."""    
    f = open(filename)
    str = f.read()
    f.close()
    return str


def str2file(str, filename):
    """ Make a string be a full file."""    
    f = open(filename, "w")
    f.write(str)
    f.close()

def del_if_can(path, name):
    """Delete name from dir path if possible.  Return true on success."""
    fn = os.path.join(path, name)
    if os.path.exists(fn):
        os.remove(fn)
        return True
    return False

def if_can(action, src, dest):
    """Perform action (move/copy/leave) if can.  Return true if src exists."""
    if os.path.exists(src):
        action(src, dest)
        return True
    return False

def new_file_name(base):
    """ Return the first new file name, base or base_#, # = 2, 3, 4...."""
    if not os.path.exists(base):
        return base
    n = 2
    while os.path.exists(base+'_'+str(n)):
        n += 1
    return base+'_'+str(n)

def make_file_name(title, remove_blanks):
    """If remove_blanks, return string with all non +-,alphanum seqs replaced 
    by _, and initial or final _ removed; otherwise convert to windows format,
    replacing with _ sequences of illegal chars: 
    \ / : * ? " < > |
    """
    if remove_blanks:
        s = re.sub("[^-+a-zA-Z0-9]+",'_', title)
    else:
        s = re.sub("[\\\\/:\*\?\"\<\>\|]+",'_', title)    
    return re.sub("\A_|_\Z","", s)

def quote_if_blanks(name):
    """Return name wrapped in quotes if name contains a blank.
    """
    if name.find(" ") >= 0:
        return '"'+name+'"'
    return name

def full_pic_name(path, filebase, suffix=""):
    "Return true if pic file with path and filename exists."
    return os.path.join(path, filebase+suffix+PIC_EXT)

def pic_exists(path, filebase, suffix=""):
    "Return true if pic file with path and filename exists."
    return os.path.exists(full_pic_name(path, filebase, suffix))

def convert_pic_PIL(source, dest, is_thumb, dim, rotation):
    """Convert pic to have specified dimensions and rotation.
    Any rotation is done first.
    If dim is not null, 
       Scale, keeping aspect ratio to maximize size within dim.
       If isThumb, 
         pad the picture so the dimensions are exactly dim
    Return error string or None
    """
    # only called if a change is to be made
    try:
        im = PIL.Image.open(source)
    except IOError:
        return "Error opening " + source
    if rotation:
        im = im.rotate(rotation) # angle in deg counter cw
    dim1 = im.size
    if dim:
        dim2 = keep_aspect(dim1, dim)
        im =im.resize(dim2, PIL.Image.ANTIALIAS)
        if is_thumb and dim2 != dim:
            # force exact size by padding with white if necessary
            im_temp = im
            im = PIL.Image.new("RGB", dim, (255, 255, 255))
            im.paste(im_temp, ((dim[0]-dim2[0])/2, (dim[1]-dim2[1])/2)) #center
    # ? if source = dest: rename orig, save new, delete renamed?
    try:
        im.save(dest)
    except IOError:
        return "Error saving " + dest

convert_pic = convert_pic_PIL

def keep_aspect(size, newmax):
    """Scale size, keeping aspect ratio, to largest within size newmax."""
    if not size[0] or not size[1]:
        return map(min, size, newmax)
    d0 = (newmax[1]*size[0])/size[1] # if keep newmax[1], other dim is d0
    if newmax[0] >= d0:  # other dimension fits
      return (d0, newmax[1])
    return(newmax[0], (newmax[0]*size[1])/size[0]) # keep newmax[0]


class Pic_doc: #=============================================================
    """Picture comment record class"""
    
    # will need extra ext field if multiple image formats allowed
    def __init__(self, title="", text="", orig_name="", want_name="",
                 rotation=0):
        self.title = title.strip() 
        self.text = text.strip()
        
        # all names exclude PIC_EXT
        self.orig_name = orig_name.strip() # starting name in file system
        self.want_name = want_name.strip() # desired name (may be a repeat)
        self.name = ""                     # final name: want_name + possible #
        self.rotation = rotation 

    def __repr__(self):
        rot_sym = Pic_doc.GET_ROTATION_SYMBOL[self.rotation]
        return  "%s%s %s%s\n%s\n%s\n" \
                 % (FILENAME_MARK, quote_if_blanks(self.orig_name), 
                    quote_if_blanks(self.want_name), rot_sym, 
                    self.title,
                    self.text)

    GET_ROTATION_SYMBOL = {90: "<", -90: ">", 180: ">>", 0: ""}

    def get_rotation(s):
        """Allow any combination of '>','<':  returning -90, 0, 90 or 180."""
        return 90*((s.count('<') - s.count('>') + 5) % 4 - 1)

    get_rotation = staticmethod(get_rotation)

    def strip_rotation(s):
        return s.replace("<","").replace(">","").strip()

    strip_rotation = staticmethod(strip_rotation)

    def get_title(self):
        "return pic's specified title or default."
        if self.title: return self.title
        return self.orig_name

# end of Pic_doc =========================================================


class Gallery: #==========================================================
    """ Parts of a Picture Gallery and parameters for transforming
    individual pictures."""
    
    def __init__(self, gal_dir): # gal_dir is abs path
        # source/dest/both?
        self.gal_dir = gal_dir.strip() # may change with dir renaming
        self.title = ""          # will be from gal_dir or existing doc file
        self.intro = ""          # will be from existing doc file
        self.docs = []           # will be from wildcards or existing doc file


    def __repr__(self):
        return self.title_intro2str() + \
               "\n".join([str(pd) for pd in self.docs])


    def title_intro2str(self):
        return self.title + "\n\n" + self.intro + "\n" 


    # parse pic_docs, support methods: ---------------------------------------

    def get_docs(self):
        """ Initialize docs from doc_file information or image file list.
        """
        
        if os.path.exists(os.path.join(self.gal_dir, PICDOCFILE)):
            self.parse_pic_docs()
        else:
            self.make_bare_docs()


    def make_bare_docs(self):
        """Generate a list of PIC_EXT files to use in docs.        
        Always exclude pics ending in THUMB or MEDIUM.
        """
        n = len(PIC_EXT)
        try:
            files = os.listdir(self.gal_dir)
        except OSError:
            raise Exception,  "Cannot read names of files in " + self.gal_dir 
        picnames = [name[:-n] for name in filter(Gallery.is_base_pic, files)]
        picnames.sort()       
        self.docs = [Pic_doc("","", name, "") for name in picnames]


    def is_base_pic(fname):
        """ Filter pic files that are not the scaled ones."""
        return fname.lower().endswith(PIC_EXT) and \
               not fname.lower().endswith(MEDIUM+PIC_EXT) and \
               not fname.lower().endswith(THUMB+PIC_EXT)       

    is_base_pic = staticmethod(is_base_pic)

    def parse_pic_docs(self):
        """Parse doc_file with its documentation for a collection of pictures.

        Picture Documentation File Format:
        <collection title - one line>
        <multiline text of gallery intro>
        ###<orig pic name base> [<explicit new wanted name base>][rotation]
        <pic title - one linem - keep it short>
        <multiline text of picture description>

        ... more pictures and their data, each starting with marker ###

        The rotation may be '>', '<' or '>>' indicating the number of times and
        direction to rotate the top of the picture 90 degrees.
        
        All pic files are assumed to end with PIC_EXT, so it is added 
        automatically to the base in the file.
        
        The orig pic name is used to find pictures.  They are renamed to the
        new wanted name base if it exists (plus a number if duplicates exist).

        If a title is omitted, the multiline text after it must also be omitted.
        The multiline text is displayed as html, so lines are wrapped.  One
        translation:  double carriage returns are replaced by new paragraphs.
        For now, other html markup that is desired must be added explicitly.

        """
        full_doc_file = os.path.join(self.gal_dir, PICDOCFILE)
        if not os.path.exists(full_doc_file): # shouldn't happen:  just checked!
            raise Exception,  "Aborting: %s does not exist." % full_doc_file
        if not os.access(full_doc_file, os.R_OK):
            raise Exception,  "Aborting: Cannot open %s." % full_doc_file
        allNames = []
        f = open(full_doc_file)
        (self.title, self.intro, line) = Gallery.parse_title_text(f)
        (rotation, names) = Gallery.process_filename_line(line)
        if not names:
            raise Exception,  ("In file %s:\n" +
                   "Missing a file name on line after collection title:\n"+
                   "  %s") % (full_doc_file, self.title)
        while names:
            orig_file = names[0];
            if orig_file in allNames: # if the user edited the wrong part
                raise Exception,  ("In file %s:\n" +
                       "Duplicate file name:  %s") % (full_doc_file, orig_file)
            allNames.append(orig_file)    
            if len(names) > 1:
                want_file = names[1]
                if len(names) > 2: 
                    # display(names.__repr__()+"\n")
                    raise Exception,  \
                      ("In file %s:" +
                      "Error in the picture file name line:\n" +
                      "   %s") % (full_doc_file, line)
            else:
                want_file = ""
            (p_title, text, line) = Gallery.parse_title_text(f)
            self.docs.append(
                        Pic_doc(p_title, text, orig_file, want_file, rotation))
            (rotation, names) = Gallery.process_filename_line(line)
        f.close()
            
        
    def parse_title_text(f):
        """Extract (title line, multiline text, delimiter line) from file f."""
        line = Gallery.nonblank_line(f).strip()
        if not line or line.startswith(FILENAME_MARK):
            return ("", "", line)
        title = line
        text = ""
        line = Gallery.nonblank_line(f)
        while line and not line.lstrip().startswith(FILENAME_MARK):
            text += line
            line = f.readline() # includes newline
        return (title, text.rstrip(), line.strip())

    parse_title_text = staticmethod(parse_title_text)

    
    def process_filename_line(line):
        """Strip FILENAME_MARK and return a list of tokens.
        Return: (rotation, token-list) where the list is [] if FILENAME_MARK is
                 missing or there is nothing after the FILENAME_MARK.
                 Include 3 or more tokens if illegal.
        """
        
        if not line.startswith(FILENAME_MARK):
            return ("", [])
        line = line[F_MARK_LEN:]
        rotation = Pic_doc.get_rotation(line)
        line = Pic_doc.strip_rotation(line)
        return (rotation, Gallery.tokenize(line))

    process_filename_line = staticmethod(process_filename_line)


    def tokenize(str):
        """Return a list of blank separated, possibly quote enclosed 
        strings.  Assume quote at end if one is unmatched.
        """
        list = []
        str = str.strip()
        while str:
            if str.startswith('"'):
                n = str.find('"', 1)
                if n == -1:
                    list.append(str[1:].strip())
                    return list
                else:
                    list.append(str[1:n].strip())
                    str = str[n+1:].strip()
            else:
                return list + str.split()
        return list
    
    tokenize = staticmethod(tokenize)


    def nonblank_line(f):
        """Skip empty whole lines in a file, but allow EOF (empty string).
        f:      file object to read
        Return: the first nonblank line or "" for EOF
                while line and not line.lstrip():
        """

        line = f.readline()
        while line and not line.lstrip():
            line = f.readline()
        return line

    nonblank_line = staticmethod(nonblank_line)        
    # end pic_doc parsing support  -----------------------------------------

    def find_thumb_dim(self, dim):
        """Return the first thumb dim or else the arg dim ."""
        for pd in self.docs:
            size = self.get_dim(pd.orig_name, THUMB)
            if size[0]:
                return size
        return dim


    def find_medium_dim(self, dim):
        """Return the largest dimension of medium."""
        old_dim = (0, 0)
        for pd in self.docs:
            size = self.get_dim(pd.orig_name, MEDIUM)
            old_dim = map(max, old_dim, size)
        if old_dim[0]:
            dim = old_dim
        return dim


    def wrong_dim(self, name, suffix, lim):
        size = self.get_dim(name, suffix)
        if suffix == THUMB:
            return size != lim
        return size != keep_aspect(size, lim)

     
    def get_dim(self, name, suffix = ""):
        """Return size or (0,0) if pic missing."""
        file = full_pic_name(self.gal_dir, name, suffix)
        if os.path.exists(file):
            im = PIL.Image.open(file)
            return im.size
        return (0,0)


    # for each gallery: # (including first)
    def check_dims_and_files(self, force, thumb_dim, medium_dim, missing):
        """Add any required missing original pic to missing.
        """
        for pd in self.docs:
            if not pic_exists(self.gal_dir, pd.orig_name) and \
                  (force or pd.rotation or 
                   self.wrong_dim(pd.orig_name, THUMB, thumb_dim) or
                   self.wrong_dim(pd.orig_name, MEDIUM, medium_dim)):
                missing.append("pic %s in %s" %(pd.orig_name, self.gal_dir))


    def new_unique_names(self, use_titles, name_count, remove_blanks):
        """Set new name bases in docs: 
        First choose want_name or modified title or orig_name.
        Update name_count and use the counts to make unique names if necessary.        
        """
        
        for pd in self.docs:
            if pd.want_name:
                pd.name = pd.want_name
            elif pd.title and use_titles:
                from_title = make_file_name(pd.title, remove_blanks)
                pd.name = from_title[:MAX_BASE]
            else: 
                # ? assume allow orig name to be changed to fit flag
                pd.name = make_file_name(pd.orig_name, remove_blanks)
            # map base name to a 1-element list (the count), so it is mutable
            count_in_list = name_count.setdefault(pd.name.lower(),[0])
            count_in_list[0] += 1 # update count
            count = count_in_list[0]
            if count > 1:
                pd.name = pd.name + "_" + str(count)

                    
    def check_names_change(self):
        """return true if any new name does not exactly match orig."""
        for pd in self.docs:
            if pd.name != pd.orig_name:
                return True
        return False


    def check_rotations(self):
        """return true if any pic is to be rotated."""
        for pd in self.docs:
            if pd.rotation:
                return True
        return False


    def make_temp_orig_names(self):
        """ change orig_name/actual files if there will be a conflict in the
            middle of renaming.
        """
        
        all_pic_f = [f[:-len(PIC_EXT)] for f in \
                 fnmatch.filter(os.listdir(self.gal_dir), "*"+PIC_EXT)]
        pic_base = sets.Set()
        # could make custom set with hash and eq based on fnmatch, but
        #  having a more extensive list here, using lower(), does not hurt safety
        for f in all_pic_f:
            if f.lower().endswith(THUMB):
                pic_base.add(f[:-len(PIC_EXT)])
            elif f.lower().endswith(MEDIUM):
                pic_base.add(f[:-len(MEDIUM)])
            else: 
                pic_base.add(f)

        # form a tempory file name base
        while True:
            tempbase = "t"+str(random.randint(1, 999)).zfill(3)+"n"
            if not fnmatch.filter(all_pic_f, tempbase+"*"):
                break
            
        for n, pd in enumerate(self.docs):
            if pd.orig_name != pd.name and pd.name in pic_base:
                tempname = tempbase+str(n+1)
                for suffix in ("", THUMB, MEDIUM):
                    # ! don't catch wierd file errors here
                    if_can(os.rename,
                           full_pic_name(gal_dir,pd.orig_name,suffix),
                           full_pic_name(gal_dir,tempname,suffix) )
                pd.orig_name = tempname
                

    def new_name_sizes(self, gal_dir, orig_pic_action, thumb_dim, medium_dim,
                       force_conversion, display):
        """ Move, rename, resize, rotate pic files."""
                                                      
        if orig_pic_action == 'c':
            file_action = shutil.copy2
        elif orig_pic_action == 'm':
            file_action =  shutil.move
        else: # l - leave
            file_action = lambda src, dest: None
        display("Resizing/renaming/moving/rotating files.") 
        tot = len(self.docs)
        done = 0
        for pd in self.docs:
            # handle original move/copy/rotation
            orig_from = full_pic_name(self.gal_dir, pd.orig_name)
            orig_to = full_pic_name(gal_dir, pd.name)
            doing_rotation = bool(pd.rotation)
            if doing_rotation:
                if orig_pic_action != 'l':
                    convert_pic(orig_from, orig_to, False, None, pd.rotation)
                    if orig_pic_action == 'm' and \
                           not fnmatch.fnmatch(orig_to, orig_from):
                        del_if_can("",orig_from)
                    pd.rotation = 0
            elif not fnmatch.fnmatch(orig_to, orig_from):
                if_can(file_action, orig_from, orig_to)
            # display(".")
            if orig_pic_action == 'l':
                orig_to = orig_from
            # now orig, if it exists is at orig_to, rotated if specified,
            #   and pd.rotation indicates if a further rotation is needed in
            #   small pics (only if -o leave option)

            for (suffix, dim) in ((THUMB, thumb_dim),(MEDIUM, medium_dim)):
                source = full_pic_name(self.gal_dir, pd.orig_name,suffix)
                dest = full_pic_name(gal_dir, pd.name,suffix)
                if (not os.path.exists(orig_to) or
                         (not force_conversion and not doing_rotation and
                          not self.wrong_dim(pd.orig_name, suffix, dim))):
                    if not fnmatch.fnmatch(source, dest):
                        shutil.move(source, dest)
                else: # do some conversion
                    convert_pic(orig_to, dest, suffix == THUMB,
                                dim, pd.rotation)
                    if not fnmatch.fnmatch(source, dest):
                        del_if_can("",source)
                # display(".")
            pd.rotation = 0
            done += 1
            display("Done with %d of %d images" % (done, tot))


    def change_orig_names(self):
        """Shift name to orig name in docs; remove name.
        """
        for pd in self.docs:
            if pd.name != pd.orig_name:
                pd.orig_name = pd.name
            pd.name = ""


    def del_old_html(self):
        """delete old #.html, and also index.html if 1.html exists."""
        n = 1
        while del_if_can(self.gal_dir, str(n)+".html"):
            n += 1 
        if n > 1: # Assume 1.html is an unusual name -- if found, assume
                  #   the common name index.html is a deletable gallery file.
            del_if_can(self.gal_dir,"index.html")


# end of class Gallery =========================================================
