ExtractZip2.py
# coding=utf-8
""" 
ExctractZip.py 
Author: Fredi Hartmann aka ADP 
Startdate: 03/08/2020 
Update:    03/09/2020 
Update:    07/22/2020 
 
Public Domain 
This script comes without any license restrictions. 
 
German Note: 
    Trotzdem das Folgende frei ist, sollte es zum guten Ton gehören, 
    den Autor in der eigenen Arbeit zu erwähnen, wenn man auf dessen 
    Arbeit zurück gegriffen hat. 
 
English note: 
    Even though the following is free, it should be good style 
    to mention the author in one's own work if one has drawn on his work. 
 
     (Online translated via https://www.deepl.com/) 
"""

from __future__ import print_function

import os
import re
import sys
from shutil import copyfileobj
from zipfile import ZipFile, ZipInfo, is_zipfile
from gzip import open as gzopen
import time
import wx

# Wether or not full pathes are written to Poser files.
# True writes something like "c:/abc/def/package/content/file.xxx"
# False writes a relative path: "content/file.xxx"
USE_FULLPATH = False
# If Full pathes are written, the installation path is hold global.
INSTALLPATH = ""
# Indentation means: level of curly brackets. Can be used for
# conditional replacements.
INDENTATION = 0
USE_INDENTATION = True
# First word of the current line to analyze/write. Can be used for
# conditional replacements.
CURRENTKEYWORD = ""
USE_KEYWORD = True
# Assume filenames ending with "z" as gziped files (*.crz, *.obz, *.fcz, etc)
ASSSUME_GZIP = True
# Poserpath holds the current position inside a poser object.
# In a conditional replace POSERPATH == "<actorname>/channels/groups" for example says
# that what ever you are will change is the name of a groupNode (part of groups).
# If POSERPATH == "<actorname>/channels/groups/groupNode", you are going to change
# a parmNodes name.
POSERPATH = None
USE_POSERPATH = True

# Unpack zips inside a zipfile or just copy.
USE_ZIPINZIP = True

# Wether or not all found pathes are replced
# or only those who reference to files inside the package.
USE_LIMITED_REPLACEMENTS = True

nr_bytes = 0
nr_files = 0
try:
    import poser

    POSERPYTHON = True
except ImportError:
    from PoserLibs import POSER_FAKE as poser

    POSERPYTHON = False

if USE_POSERPATH:
    # POSERPATH needs indentation
    USE_INDENTATION = True

# ----------------------------------------------------------------------------------------
# Keywords of lines in Poser files containing filenames to replace.
poser_keywords = "morphBinaryFile", \
                 "figureResFile", \
                 "objFileGeom", \
                 "textureMap", \
                 "bumpMap", \
                 "file", \
                 "runPythonScript",

# ----------------------------------------------------------------------------------------

# Driveletters to replace unconditionally
drive_substitutes = {"d": "c", "e": "c"}

# ----------------------------------------------------------------------------------------

# Pathnames to replace (key = old, value = new).
# Keys should be all lowercase. Upper/lower case for Values are preserved.
# Replacement occures in lines starting with certain keywords (see above).
path_substitutes = {
    "runtime/textures": "Textures",
    "runtime/libraries": "Libraries",
    "runtime/geometries": "Geometries",
    "runtime/scenes": "Scenes",
}

# ----------------------------------------------------------------------------------------
# keywords in path/filenames to prevent extracting/copying.
# upper/lower case ignored.
excluded_path_keywords = (
    r"/VendorLinks",
    r"\.txt",
    r"\.pdf"
)

# ----------------------------------------------------------------------------------------

# Regular expressions to find things in analysed files
drive_regex = re.compile(r"^(" + "|".join(drive_substitutes.keys()) + r"):[/\\]", re.IGNORECASE)
poser_keyword_regex = re.compile(r"^\s*(:?" + "|".join(poser_keywords) + r")\s+(.*)")
first_word_regex = re.compile(r"^\s*?(\w+)")
# Regular expression to ignore certain pathes/files
excludepath_regex = re.compile("|".join(excluded_path_keywords), re.IGNORECASE)


# ----------------------------------------------------------------------------------------

def uncompressed_extension(tail):
    t = tail.lower()
    if t == "obz":
        return "obj"
    elif t == "crz":
        return "cr2"
    elif t == "fcz":
        return "fc2"
    elif t == "hrz":
        return "hr2"
    elif t == "mcz":
        return "mc6"
    elif t == "p2z":
        return "pz2"
    elif t == "ppz":
        return "pp2"
    else:
        return tail[:-1] + "2"


# ----------------------------------------------------------------------------------------


class PoserPath(list):
    def get_path(self):
        return "/".join(map(str, self))

    def push(self, item):
        if item:
            super(self.__class__, self).append(str(item))

    def pop_n(self, n=1):
        while self.__len__() and n:
            self.pop()
            n -= 1

    def extend_path(self, part):
        if part:
            self.extend(part.split("/"))

    def has(self, keyword):
        # to be able to use "in" as a function
        return keyword in self

    def __str__(self):
        return "|".join(self)


# ----------------------------------------------------------------------------------------
#    Sadly Poser-Python (version 2.710, patchlevel from 2017) does not support
#    urllib3, nor any other lib for easy https access.
#    So I will left this untouched atm. Maybe anyone else can look into that-

def download_file(url, report_callback=None):
    if report_callback is None:
        report_callback = lambda a, b, c: True

    import urllib
    try:
        filename, headers = urllib.urlretrieve(url, reporthook=report_callback)
    except Exception as err:
        filename = err
    finally:
        del urllib

    return filename


def is_url(filename):
    for scheme in ("http://", "ftp://"):
        if filename.startswith(scheme):
            return True
    return False


# ----------------------------------------------------------------------------------------

def collect_filenames(zipfilename):
    """ 
    Collects all filenames inside a zip-file and 
    returns a list. 
    if global variable USE_LIMITED_REPLACEMENTS is False, 
    the list will be empty. 
    """
    tmp_list = list()
    if USE_LIMITED_REPLACEMENTS:
        with ZipFile(zipfilename, "r") as zfile:
            for info in zfile.infolist():
                if info.file_size > 0:
                    _path = info.filename
                    _path = _path.replace("\\", ":").replace("/", ":")
                    while _path[0] == "/":
                        _path = _path[1:]
                    tmp_list.append(_path)
    return tmp_list


def regex_from_list(arg):
    if USE_LIMITED_REPLACEMENTS:
        if isinstance(arg, basestring):
            arg = collect_filenames(arg)
        if arg is None or len(arg) == 0:
            return None
        assert hasattr(arg, "__iter__")
        return re.compile(r"(" + "|".join(a for a in arg) + r")$")
    else:
        return None


# ----------------------------------------------------------------------------------------

def convert(stream_in, stream_out, replaceable_files=None):
    global INDENTATION  # number of levels for {} (curly brackets)
    global CURRENTKEYWORD  # word current line starts with
    global POSERPATH

    replaceable_regex = regex_from_list(replaceable_files)
    if USE_POSERPATH:
        POSERPATH = PoserPath()

    def path_correction(_path):
        if drive_regex.match(_path):
            _path[0] = drive_substitutes[_path[0]]
        _path = _path.replace(":", "/").replace("\\", "/")
        while _path[0] == "/":
            _path = _path[1:]

        _path_lower = _path.lower()  # to avoid lots of calls to lower()
        for k, v in path_substitutes.items():
            if _path_lower.startswith(k):
                _path = v + _path[len(k):]
                break
        if USE_FULLPATH:
            _path = os.path.abspath(_path)
        return _path

    #    print("*" * 80)
    #    print(stream_out.name)
    #    print("*" * 80)
    last_indent = 0

    for line_nr, line in enumerate(stream_in):
        last_indent = INDENTATION
        if USE_INDENTATION:
            INDENTATION += line.count("{") - line.count("}")

        if USE_POSERPATH:
            if INDENTATION < last_indent:
                POSERPATH.pop_n(1)
            elif INDENTATION > last_indent:
                POSERPATH.push(CURRENTKEYWORD)

        if len(POSERPATH) > 2:
            pass

        if USE_KEYWORD:
            r = first_word_regex.match(line)
            CURRENTKEYWORD = r.group(1) if r else ""

        r = poser_keyword_regex.search(line)
        if r:
            # Keyword found, path is stored in group(2).
            found_str = r.group(2).strip().replace('"', "")
            if replaceable_regex:
                if replaceable_regex.search(found_str):
                    line = line.replace(found_str, path_correction(found_str))
                    # print("1 Replaced ({}): {}".format(found_str, line))
                else:
                    if "NO_MAP" not in line:
                        print("Left alone:", line)
            else:
                line = line.replace(found_str, path_correction(found_str))
                # print("2 Replaced ({}): {}".format(found_str, line))

        stream_out.write(line)


def convert_file(poserfile, newpath, forced_pathnames=None):
    """ 
    Convert a single poserfile or a zip-file. 
    Converted files are created in newpath with their original names 
    (poserfile in case of a single file, else the subdirectories/filenames 
    contained in the zip-archiv). 
    Content of certain files is analysed and filenames are replaced to 
    point to the new path. 
    """

    global INDENTATION  # number of levels for {} (curly brackets)
    global nr_files, nr_bytes, INSTALLPATH

    if USE_FULLPATH:
        INSTALLPATH = newpath
    last_indent = INDENTATION
    INDENTATION = 0

    def do_convert(in_name, out_name, just_copy=False, replaceable_files=None):
        if isinstance(in_name, basestring):
            try:
                fh_in = open(in_name, "r")
            except IOError as err:
                return err
        else:
            fh_in = in_name  # given as open file

        if isinstance(out_name, basestring):
            if ASSSUME_GZIP and out_name.endswith("z"):
                tmp_name = out_name + ".tmp"
                copyfileobj(fh_in, open(tmp_name, "w"))
                name, ext = out_name.rsplit(".", 1)
                out_name = name + "." + uncompressed_extension(ext)
                res = do_convert(gzopen(tmp_name, "r"), out_name)
                os.remove(tmp_name)
                return res
            else:
                try:
                    fh_out = open(out_name, "w")
                except IOError as err:
                    return err
        else:
            fh_out = out_name  # given as open file

        if just_copy:
            copyfileobj(fh_in, fh_out)
        else:
            global CURRENTKEYWORD, POSERPATH
            old = CURRENTKEYWORD, POSERPATH
            convert(fh_in, fh_out, replaceable_files)
            CURRENTKEYWORD, POSERPATH = old

        try:
            fh_out.close()
            fh_in.close()
        except IOError:
            pass

        INDENTATION = last_indent
        return None

    # end of do_convert()

    if not os.path.isdir(newpath):
        try:
            os.makedirs(newpath)
        except IOError:
            print("Can't create path '{}'".format(newpath))
            return

    if isinstance(poserfile, basestring) and is_url(poserfile):
        local_file = download_file(poserfile)
        if os.path.isfile(local_file):
            return convert_file(local_file, newpath)
        else:
            return local_file  # should contain error message

    if not os.path.exists(poserfile):
        print("Can't find poserfile '{}'".format(poserfile))
        return

    os.chdir(newpath)

    if is_zipfile(poserfile):
        replaceable_files = collect_filenames(poserfile) + (forced_pathnames or [])

        nr_files = nr_bytes = 0
        with ZipFile(poserfile, "r") as zfile:
            for info in zfile.infolist():  # type: ZipInfo
                new_filename = re.sub(r"runtime[:/\\]", "", info.filename, flags=re.IGNORECASE)
                if excludepath_regex.search(new_filename):
                    # skip unwanted content
                    continue

                if USE_ZIPINZIP and info.filename.lower().endswith(".zip"):
                    res = zfile.extract(info, newpath)
                    convert_file(res, os.path.dirname(res), replaceable_files)
                    os.remove(res)
                elif info.file_size > 0:
                    nr_files += 1
                    nr_bytes += info.file_size
                    new_filename = os.path.join(newpath, new_filename)
                    p = os.path.dirname(new_filename)
                    if not os.path.isdir(p):
                        try:
                            os.makedirs(p)
                        except IOError as err:
                            return err
                    ext = new_filename.rsplit(".")[-1]
                    just_copy = ext.lower() in "bmp gif obj jpg jpeg pdf pmd psd png " \
                                               "tif txt xmp"

                    res = do_convert(zfile.open(info.filename, "r"), new_filename, just_copy, replaceable_files)
                    if isinstance(res, IOError):
                        print(res)
                        break
    else:
        fname = os.path.basename(poserfile)
        do_convert(poserfile, os.path.join(newpath, fname))

    os.chdir(os.path.dirname(sys.argv[0]))


if POSERPYTHON:
    TEMP_CONFIG = globals().setdefault(os.path.basename(sys.argv[0]), dict())
else:
    app = wx.App()
    TEMP_CONFIG = dict(last_zip_dir="/home/fredi/Downloads",
                       last_export_dir="/home/fredi/Converter_test/")

MYPATH = os.path.dirname(sys.argv[0])
if __name__ == "__main__":
    t = None

    with wx.FileDialog(None, "Open ZIP File",
                       wildcard="ZIP files (*.zip)|*.zip",
                       defaultDir=TEMP_CONFIG.get("last_zip_dir", MYPATH),
                       style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as dlg:
        if dlg.ShowModal() != wx.ID_CANCEL:
            zip_filename = dlg.GetPath()
            TEMP_CONFIG["last_zip_dir"] = os.path.dirname(zip_filename)

            with wx.DirDialog(None, "Choose directory",
                              defaultPath=TEMP_CONFIG.get("last_export_dir", os.path.dirname(zip_filename)),
                              style=wx.DD_DEFAULT_STYLE) as dlg:
                if dlg.ShowModal() != wx.ID_CANCEL:
                    extract_path = dlg.GetPath()
                    TEMP_CONFIG["last_export_dir"] = extract_path

                    t = time.time()
                    convert_file(zip_filename, extract_path)
if t:
    print("Done in", round(time.time() - t, 2), "Seconds.")
    print(round(float(nr_bytes) / 1000 / 1000, 2), "Megabytes written to", nr_files, "files.")
else:
    print("Aborted.")