#! /usr/bin/python
# -*- coding: utf-8 -*-
#
# Utility to manage directories containing ordered collections of files
#
# Copyright © 2008 Tanguy Ortolo <tanguy@ortolo.eu>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA


import re
import os
import tempfile
from math import log10, ceil

naming_pattern = re.compile("""
^
(\d*)       # the file's ordinal number
([\s_-]+)   # a separator
(.*)        # the file's human name
$
""", re.VERBOSE)
interval_pattern = re.compile("^(\d*)-(\d*)$")
default_sep      = "_"
default_length   = 2
default_start    = 1


def shift_slice(slisse, shift) :
    """Shifts a slice."""
    start = slisse.start
    stop  = slisse.stop
    step  = slisse.step
    if start :
        start += shift
    if stop :
        stop += shift
    return slice(start, stop, step)

def shift_index(index, shift) :
    """Shifts an integer or a slice. The second argument may be a slice: in that case, its start will be used."""
    if isinstance(shift, slice) :
        shift = shift.start
    if isinstance(index, int) :
        return (index + shift)
    elif isinstance(index, slice) :
        return shift_slice(index, shift)
    else :
        raise TypeError("index is not an integer neither a slice")


class OrderedFile (object) :
    """A file of an ordered directory. It is normally fully characterised
    by a prefix number (its ordinal) and its title."""

    def __pattern(klass, sep) :
        if sep :
            return re.compile(r"^(\d)(%s)(.*)$" % sep)
        else :
            return naming_pattern
    __pattern = classmethod(__pattern)

    def is_ordered(klass, name, sep = None) :
        """Checks whether a file has a name characterizing that is is member
        of an ordered directory. Returns the string that separates the file
        ordinal number from its human name, or None if it is not ordered."""
        match = klass.__pattern(sep).match(name)
        if match :
            sep = match.group(2)
            return sep
        else :
            return None
    is_ordered = classmethod(is_ordered)

    def __init__(self, name, number = None, sep = None, length = None) :
        """Defines a new ordered file, with the given name and ordinal
        number.
        
        If no number is given, the file's name is considered to include
        it, on the form number_title. If that does not work, it is given the
        number 0."""
        self.__length = length or default_length
        self.__sep    = sep    or default_sep
        if number :
            self.__number = number
            self.__name   = name
            self.__orig_filename = self.filename
        else :
            match  = self.__pattern(sep).match(name)
            if match :
                try :
                    self.__number = int(match.group(1))
                except ValueError :
                    self.__number = 0
                self.__length   = len(match.group(1))
                self.__sep      = match.group(2)
                self.__name     = match.group(3)
            else :
                self.__number   = 0
                self.__name     = name
            self.__orig_filename = name

    def __set_number(self, number) :
        self.__number = number
        self.save()
    number = property(lambda self: self.__number, __set_number)

    def __set_sep(self, sep) :
        self.__sep = sep
        self.save()
    sep = property(lambda self: self.__sep, __set_sep)

    def __set_length(self, length) :
        self.__length = length
        self.save()
    length = property(lambda self: self.__length, __set_length)

    def __set_name(self, name) :
        self.__name = name
        self.save()
    name = property(lambda self: self.__name, __set_name)

    def filename(self) :
        """Returns the file's name, """
        return "%%0%1dd%%s%%s" % self.__length % (self.__number, self.__sep, self.__name)
    filename = property(filename)

    def __iadd__(self, number) :
        """Shifts this file's ordinal number."""
        self.number += number
        return self

    def __isub__(self, number) :
        """Shifts this file's ordinal number."""
        self.number -= number
        return self

    def __sub__(self, other) :
        return (self.number - other.number)

    def __str__(self) :
        return '<OrderedFile %s>' % self.filename

    __repr__ = __str__

    def save(self) :
        """Renames this file, according to its name and ordinal number"""
        os.rename(self.__orig_filename, self.filename)
        self.__orig_filename = self.filename

    def temp(self) :
        """Renames this file to a temporary name."""
        (tempfd, tempname) = tempfile.mkstemp(suffix = self.name, dir = ".")
        os.close(tempfd)
        os.rename(self.__orig_filename, tempname)
        self.__orig_filename = tempname

    def __cmp__(self, other) :
        return self.number.__cmp__(other.__number)


class OrderedDirectory (object) :
    def __init__(self, path, start = None, sep = None, length = None) :
        """Defines a new ordered directory, i.e. a directory which files
        are numbered with ordinal prefixes.
        
        If the directory corresponding to the given path is not ordered,
        this will order it."""
        self.path = path
        files = os.listdir(path)
        files.sort()
        self.files    = []
        to_be_ordered = (isinstance(start, int) or isinstance(sep, str) or isinstance(length, int))
        last_file     = None
        for name in files :
            current_file = OrderedFile(name)
            self.files.append(current_file)
            # If, according to the file name numbering, the current file is not
            # the previous's direct successor, this directory is to be ordered.
            # For instance, a directory containing 01_truc and 01_pouet is to
            # be ordered. A directory containing 01_truc, 03_pouet, but no
            # 02_anything is also to be ordered.
            if last_file and (not to_be_ordered) and ((current_file - last_file <> 1) or (current_file.sep <> last_file.sep) or (current_file.length <> last_file.length)) :
                to_be_ordered = True
            last_file = current_file
        if to_be_ordered :
            self.order(start, sep, length)
        else :
            if len(self.files) >= 1 :
                self.shift = self.files[0].number - 0
            else :
                self.shift = 0

    def __file_index(self, index) :
        """This gives the real index to use to get the file numbered index in
        the files list of this ordered directory."""
        return shift_index(index, -self.shift)

    def __getitem__(self, index) :
        """Returns the index'th file in this ordered directory. The first one
        is not necessarily indexed 0 nor 1."""
        return self.files[self.__file_index(index)]

    def __delitem__(self, index) :
        """Deletes the index'th file in this ordered directory. The first one
        is not necessarily indexed 0 nor 1."""
        del self.files[self.__file_index(index)]

    def insert(self, index, element) :
        """Inserts a file a the given index file in this ordered directory. The
        first one is not necessarily indexed 0 nor 1."""
        self.files.insert(self.__file_index(index), element)

    def order(self, start = None, sep = None, length = None) :
        """Renumbers the files in this directory, so that it is correctly
        ordered."""
        start = start or default_start
        length = max(default_length, ceil(log10(len(self.files))), length)
        for index in range(len(self.files)) :
            f = self.files[index]
            f.number = index + start
            # Use the first files's separator between ordinal number and human name
            # as a separator for every file in this directory.
            if sep and f.sep <> sep :
                f.sep = sep
            else :
                sep = f.sep
            if length and f.length <> length :
                f.length = length
            else :
                length = f.length
        self.shift = start

    def move_file(self, orig_index, new_index) :
        """Renumbers the file(s) indexed by orig_index (may be a slice) to new_index (and following)."""
        if isinstance(orig_index, int) :
            orig_index = slice(orig_index, orig_index + 1)
#            target = self[orig_index]
#            target.temp()
#            del self[orig_index]
#            if orig_index < new_index :
#                for index in range(orig_index, new_index) :
#                    f = self[index]
#                    f -= 1
#            elif orig_index > new_index :
#                for index in range(new_index, orig_index) :
#                    f = self[index]
#                    f += 1
#            target.number = new_index
#            self.insert(new_index, target)
        if isinstance(orig_index, slice) :
            if orig_index.step :
                return NotImplemented
            targets = self[orig_index]
            # Giving uniq temporary names to the files to move, so that they
            # cannot be overwritten when moving files that are between their
            # current and new locations.
            map(OrderedFile.temp, targets)
            number = orig_index.stop - orig_index.start
            del self[orig_index]
            # Moving intermediary files.
            if orig_index.start < new_index :
                for index in range(orig_index.start, new_index) :
                    f = self[index]
                    f -= number
            elif orig_index.start > new_index :
                for index in reversed(range(new_index, orig_index.start)) :
                    f = self[index]
                    f += number
            # Giving the files to move their target names.
            for index in range(len(targets)) :
                target = targets[index]
                target.number = new_index + index
                self.insert(target.number, target)
        else :
            raise TypeError("index is not an integer neither a slice")


    def shift_file(self, orig_index, shift) :
        """Renumbers the file indexed by orig_index to new_index + shift."""
        self.move_file(orig_index, shift_index(shift, orig_index))

    def __str__(self) :
        return "<OrderedDirectory: %s>" % self.path

    __repr__ = __str__


if __name__ == "__main__" :
    from optparse import OptionParser

    usage = """Usage:
   %(prog)s
   %(prog)s SRC DST
   %(prog)s SRC {+|-}SHIFT

Orders files in the current directory, prefixing them by an ordinal number, if it is not already the case.

With no argument, does nothing more.
With two arguments, moves the file(s) ordered SRC to DST, or shifts it according to SHIFT.
SRC may be an interval of ordinals, separated with a dash. The DST is then the target ordinal of their first element, the other ones following.

Example:
   Let be a directory containing four files:
   bar    baz    foo    qux
  
   Let's apply %(prog)s several times.
   %(prog)s:
   01_bar 02_baz 03_foo 04_qux
   
   %(prog)s 4 3:
   01_bar 02_baz 03_qux 04_foo
   
   %(prog)s 1-3 4
   01_foo 02_bar 03_baz 04_qux
   
Limitations:
    Trying to shift files outside the ordinals covered by the gives funny results.""" % {"prog" : "%prog"}

    opt_parser = OptionParser(usage = usage)
    opt_parser.disable_interspersed_args()
    opt_parser.add_option('-s', '--separator', '--sep', metavar = "SEPARATOR", help = "string to use for separating files' ordinals from files' names")
    opt_parser.add_option('-t', '--start', type = "int", metavar = "START", help = "first index to use: defaults to %(default_start)d" % {'default_start' : default_start})
    opt_parser.add_option('-l', '--length', '--prefix-length', type = "int", metavar = "PREFIX_LENGTH", help = "ordinal prefix width( number of digits): defaults to max(%(default_length)d, log10(number of files))" % {'default_length' : default_length})
    (options, args) = opt_parser.parse_args()

    if len(args) > 2 :
        opt_parser.error("too many arguments")

    def directory(options) :
        return OrderedDirectory('.', start = options.start, sep = options.separator, length = options.length)

    if len(args) == 0 :
        d = directory(options)
    elif len(args) == 1 :
        opt_parser.error("too much or too many arguments, should be zero or two")
    elif len(args) == 2 :
        try :
            src = int(args[0])
        except ValueError :
            match = interval_pattern.match(args[0])
            if match :
                src = slice(int(match.group(1)), int(match.group(2)) + 1)
            else :
                opt_parser.error("source ordinal: invalid integer or interval value: %s" % args[0])
        try:
            dst = int(args[1])
        except ValueError :
            opt_parser.error("destination ordinal: invalid integer value: %s" % args[1])
        d = directory(options)
        if args[1].startswith('+') or args[1].startswith('-') :
            d.shift_file(src, dst)
        else :
            d.move_file(src, dst)

