diff --git a/.gitignore b/.gitignore index e3947bb..67b4408 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ /build/ /dist/ /vntools.egg-info/ +/.idea/ +/.vscode/ \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD index 0c6ef83..f0bb5f2 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -3,7 +3,7 @@ _pkgname=VNTools pkgname=vntools-git -pkgver=2.0.e5bf961 +pkgver=2.1.ab883708 pkgrel=1 pkgdesc="Collection of tools used by VienDesu! Porting Team" arch=("any") @@ -16,7 +16,7 @@ sha256sums=("SKIP") pkgver() { cd "${srcdir}/${_pkgname}" - printf "2.0.%s" "$(git rev-parse --short HEAD)" + printf "2.1.%s" "$(git rev-parse --short HEAD)" } build() { diff --git a/README.md b/README.md index f9b3c17..eb026d5 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ Collection of tools used by VienDesu! Porting Team ### Installation #### Download from releases: - * Windows - [x64](https://git.viende.su/VienDesuPorting/VNTools/releases/download/2.0.0/vntools-win-x64.zip) - * Linux - [x86_64](https://git.viende.su/VienDesuPorting/VNTools/releases/download/2.0.0/vntools-linux-x86_64.zip) [arm64](https://git.viende.su/VienDesuPorting/VNTools/releases/download/2.0.0/vntools-linux-arm64.zip) - * MacOS - [x86_64](https://git.viende.su/VienDesuPorting/VNTools/releases/download/2.0.0/vntools-darwin-x86_64.zip) [arm64](https://git.viende.su/VienDesuPorting/VNTools/releases/download/2.0.0/vntools-darwin-arm64.zip) + * Windows - `TODO` + * Linux - `TODO` + * MacOS - `TODO` #### Build tools as binaries: - * Run `./build.sh` for UNIX + * Run `./build.sh` on UNIX * Run `.\build.bat` for Windows #### Install as python package: diff --git a/build.bat b/build.bat index 821ed6c..9c097ac 100755 --- a/build.bat +++ b/build.bat @@ -12,6 +12,8 @@ python -m nuitka --jobs=%NUMBER_OF_PROCESSORS% --output-dir=output --follow-impo move /Y output\unrenapk.exe output\bin python -m nuitka --jobs=%NUMBER_OF_PROCESSORS% --output-dir=output --follow-imports --onefile --output-filename=vnds2renpy vnds2renpy || goto :exit move /Y output\vnds2renpy.exe output\bin +python -m nuitka --jobs=%NUMBER_OF_PROCESSORS% --output-dir=output --follow-imports --onefile --output-filename=rpatool rpatool || goto :exit +move /Y output\rpatool.exe output\bin echo "Done! You can get binaries into output\bin directory" :venv_error diff --git a/build.sh b/build.sh index ffbd2b4..83e0e1c 100755 --- a/build.sh +++ b/build.sh @@ -21,4 +21,6 @@ python3 -m nuitka "${jobs}" --output-dir=output --onefile --follow-imports --out mv output/unrenapk output/bin python3 -m nuitka "${jobs}" --output-dir=output --onefile --follow-imports --output-filename=vnds2renpy vnds2renpy mv output/vnds2renpy output/bin +python3 -m nuitka "${jobs}" --output-dir=output --onefile --follow-imports --output-filename=rpatool rpatool +mv output/rpatool output/bin echo "Done! You can get binaries into output/bin directory" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 01df088..59ab9fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,22 +5,24 @@ requires = [ build-backend = "setuptools.build_meta" [tool.setuptools] -packages = ["vnrecode", "unrenapk", "vnds2renpy"] +packages = ["vnrecode", "unrenapk", "vnds2renpy", "rpatool"] include-package-data = true [tool.setuptools.package-data] 'vnrecode' = ['*.py'] 'vnds2renpy' = ['*.py'] 'unrenapk' = ['*.py'] +'rpatool' = ['*.py'] [project.scripts] vnrecode = "vnrecode.__main__:init" vnds2renpy = "vnds2renpy.__main__:main" unrenapk = "unrenapk.application:launch" +rpatool = "rpatool.rpatool:main" [project] name = "vntools" -version = "2.0.0" +version = "2.1-dev" requires-python = ">= 3.11" dependencies = [ "Pillow>=10.3.0", diff --git a/rpatool/README.md b/rpatool/README.md new file mode 100644 index 0000000..093711f --- /dev/null +++ b/rpatool/README.md @@ -0,0 +1,124 @@ +rpatool +======= + +This is modified version of a [rpatool](https://github.com/shizmob/rpatool) by [shizmob](https://github.com/shizmob). + +This is a simple tool allowing you to create, modify and extract Ren'Py Archive (.rpa/.rpi) files. +Currently, only writing to RPAv2/RPAv3 archives is supported. + +Usage +----- + rpatool [-l|-x|-c|-d|-a] [-o OUTFILE] [-2] [-3] [-k KEY] + [-p COUNT] [-h] [-v] [-V] + ARCHIVE [FILE [FILE ...]] + + + positional arguments: + ARCHIVE The Ren'py archive file to operate on + FILE Zero or more files to operate on + + actions: + -l, --list List files in archive ARCHIVE + -x, --extract Extract FILEs from ARCHIVE + -c, --create Creative ARCHIVE from FILEs + -d, --delete Delete FILEs from ARCHIVE + -a, --append Append FILEs to ARCHIVE + + optional arguments: + -o OUTFILE, --outfile OUTFILE + An alternative output archive file when appending to or + deleting from archives, or output directory when extracting. + -2, --two Use the RPAv2 format for creating/appending to + archives + -3, --three Use the RPAv3 format for creating/appending to + archives (default) + -k KEY, --key KEY The obfuscation key used for creating RPAv3 archives + (default: 0xDEADBEEF) + -p COUNT, --padding COUNT + The maximum number of bytes of padding to add between + files (default: 0) + --all If specified, extracts all .rpa archives in the current directory + -h, --help Print this help and exit + -v, --verbose Be a bit more verbose while performing operations + -V, --version Show version information + + The FILE argument can optionally be in ARCHIVE=REAL format, mapping a file in + the archive file system to a file on your real file system. An example of + this is: rpatool -x test.rpa script.rpyc=/home/foo/test.rpyc + +Examples +-------- + rpatool -x foo.rpa +Will extract every file from `foo.rpa`into the current directory, making subdirectories when necessary. + + rpatool -o output -x foo.rpa script.rpyc ui.png +Will extract the files `script.rpyc` and `ui.png` from `foo.rpa` into the directory `output`. + + rpatool -c bar.rpa test.jpg script.rpy sprites +Will create the archive `bar.rpa`, containing the files `test.jpg`, `script.rpy` and the directory `sprites`. + + rpatool -p 25 -k 12345 -c bar.rpa movies=C:\projects\vn\movies +Will create the archive `bar.rpa` with the obfuscation key `0x12345` and maximum padding of `25`, taking files from `C:\projects\vn\movies` and placing them in the archive folder `movies`. + + rpatool -l baz.rpa +Will list all files in the archive `baz.rpa`. + + rpatool -v -a foo.rpa sprites=sprites_new +Will add all files from the directory `sprites_new` to the directory `sprites` in the archive, giving more information about what it's doing. + + rpatool -o bar_new.rpa -d bar.rpa foo.jpg +Will remove the file `foo.jpg` from the archive `bar.rpa`, storing the result archive in `bar_new.rpa`. + + rpatool -all game/ +Will extract all the files from all .rpa archives in a `game/` folder. + +API +--- +`rpatool` can also be included in any other project (following the license conditions, of course) to provide the `RenPyArchive` class. +A small overview: + + RenPyArchive([file = None], [version = 3], [padlength = 0], [key = 0xDEADBEEF], [verbose = False]) +The constructor, which will optionally load an archive file. + +`file`: the archive file to open. If None, no archive will be attempted to open. + +`version`: the archive format version used to save the archive when `RenPyArchive.save([file])` is called. Default: 3 + +`padlength`: the maximum number of bytes of padding to put between files when saving. Default: 0 + +`key`: the obfuscation key used when saving RPAv3 archives. Default: 0xDEADBEEF + +`verbose`: print info on what we are doing to the command line. Default: False + + RenPyArchive.load(filename) +Loads an archive file from `filename`. Will raise an `IOError` if the file can't be accessed, or a `ValueError` if the file is not detected as a Ren'Py archive. + + RenPyArchive.save([filename]) +Save the archive to `filename`. Will raise `ValueError` if the filename isn't given with `filename`, nor previously defined, or an `IOError` if it couldn't save the file. + + RenPyArchive.list() +Give a list of all filenames currently in the archive. + + RenPyArchive.has_file(filename) +Returns True if `filename` is found in the archive, False otherwhise. + + RenPyArchive.add(filename, content) +Add a file to the archive with file `filename` and contents `content`. Will raise a `ValueError` if the filename already exists in the archive. + + RenPyArchive.change(filename, content) +Change the contents of a current file in the archive. Will raise an `IOError` if the file isn't known in the archive. + + RenPyArchive.remove(filename) +Remove `filename` from the archive. Will raise an `IOError` if the filename isn't known in the archive. + + RenPyArchive.read(filename) +Read and return the content of file `filename` in the archive. Will raise an `IOError` if the filename isn't known in the archive. + +Disclaimer +---------- +This tool is intended for use with files on which the authors allowed modification of and/or extraction from ONLY and the unpermitted use on files where such consent was not given is highly discouraged, and most likely a license violation as well. +Support requests for help with dealing with such files will not be answered. + +Credits +------- +Credits for the creation of the Ren'Py archive format and the reference code in Ren'Py go to renpytom. diff --git a/rpatool/__init__.py b/rpatool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rpatool/__main__.py b/rpatool/__main__.py new file mode 100644 index 0000000..b76bbbb --- /dev/null +++ b/rpatool/__main__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from rpatool import rpatool + +if __name__ == '__main__': + rpatool.main() \ No newline at end of file diff --git a/rpatool/rpatool.py b/rpatool/rpatool.py new file mode 100644 index 0000000..841fe54 --- /dev/null +++ b/rpatool/rpatool.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 + +from __future__ import print_function +import sys +import os +import codecs +import errno +import random +try: + import pickle5 as pickle +except: + import pickle + if sys.version_info < (3, 8): + print('warning: pickle5 module could not be loaded and Python version is < 3.8,', file=sys.stderr) + print(' newer Ren\'Py games may fail to unpack!', file=sys.stderr) + if sys.version_info >= (3, 5): + print(' if this occurs, fix it by installing pickle5:', file=sys.stderr) + print(' {} -m pip install pickle5'.format(sys.executable), file=sys.stderr) + else: + print(' if this occurs, please upgrade to a newer Python (>= 3.5).', file=sys.stderr) + print(file=sys.stderr) + +if sys.version_info[0] >= 3: # Python 3 + def _unicode(text): + return text + + def _printable(text): + return text + + def _unmangle(data): + if type(data) == bytes: + return data + else: + return data.encode('latin1') + + def _unpickle(data): + # Specify latin1 encoding to prevent raw byte values from causing an ASCII decode error. + return pickle.loads(data, encoding='latin1') + +elif sys.version_info[0] == 2: # Python 2 + def _unicode(text): + if isinstance(text, unicode): # You can ignore 'Unresolved reference', because you never hit this block actually + return text + return text.decode('utf-8') + + def _printable(text): + return text.encode('utf-8') + + def _unmangle(data): + return data + + def _unpickle(data): + return pickle.loads(data) + +class RenPyArchive: + file = None + handle = None + + files = {} + indexes = {} + + version = None + padlength = 0 + key = None + verbose = False + + RPA2_MAGIC = 'RPA-2.0 ' + RPA3_MAGIC = 'RPA-3.0 ' + RPA3_2_MAGIC = 'RPA-3.2 ' + + # For backward compatibility, otherwise Python3-packed archives won't be read by Python2 + PICKLE_PROTOCOL = 2 + + def __init__(self, file=None, version=3, padlength=0, key=0xDEADBEEF, verbose=False): + self.padlength = padlength + self.key = key + self.verbose = verbose + + if file is not None: + self.load(file) + else: + self.version = version + + def __del__(self): + if self.handle is not None: + self.handle.close() + + # Determine archive version. + def get_version(self): + self.handle.seek(0) + magic = self.handle.readline().decode('utf-8') + + if magic.startswith(self.RPA3_2_MAGIC): + return 3.2 + elif magic.startswith(self.RPA3_MAGIC): + return 3 + elif magic.startswith(self.RPA2_MAGIC): + return 2 + elif self.file.endswith('.rpi'): + return 1 + + raise ValueError('the given file is not a valid Ren\'Py archive, or an unsupported version') + + # Extract file indexes from opened archive. + def extract_indexes(self): + self.handle.seek(0) + indexes = None + + if self.version in [2, 3, 3.2]: + # Fetch metadata. + metadata = self.handle.readline() + vals = metadata.split() + offset = int(vals[1], 16) + if self.version == 3: + self.key = 0 + for subkey in vals[2:]: + self.key ^= int(subkey, 16) + elif self.version == 3.2: + self.key = 0 + for subkey in vals[3:]: + self.key ^= int(subkey, 16) + + # Load in indexes. + self.handle.seek(offset) + contents = codecs.decode(self.handle.read(), 'zlib') + indexes = _unpickle(contents) + + # Deobfuscate indexes. + if self.version in [3, 3.2]: + obfuscated_indexes = indexes + indexes = {} + for i in obfuscated_indexes.keys(): + if len(obfuscated_indexes[i][0]) == 2: + indexes[i] = [ (offset ^ self.key, length ^ self.key) for offset, length in obfuscated_indexes[i] ] + else: + indexes[i] = [ (offset ^ self.key, length ^ self.key, prefix) for offset, length, prefix in obfuscated_indexes[i] ] + else: + indexes = pickle.loads(codecs.decode(self.handle.read(), 'zlib')) + + return indexes + + # Generate pseudorandom padding (for whatever reason). + def generate_padding(self): + length = random.randint(1, self.padlength) + + padding = '' + while length > 0: + padding += chr(random.randint(1, 255)) + length -= 1 + + return bytes(padding, 'utf-8') + + # Converts a filename to archive format. + def convert_filename(self, filename): + (drive, filename) = os.path.splitdrive(os.path.normpath(filename).replace(os.sep, '/')) + return filename + + # Debug (verbose) messages. + def verbose_print(self, message): + if self.verbose: + print(message) + + # List files in archive and current internal storage. + def list(self): + return list(self.indexes.keys()) + list(self.files.keys()) + + # Check if a file exists in the archive. + def has_file(self, filename): + filename = _unicode(filename) + return filename in self.indexes.keys() or filename in self.files.keys() + + # Read file from archive or internal storage. + def read(self, filename): + filename = self.convert_filename(_unicode(filename)) + + # Check if the file exists in our indexes. + if filename not in self.files and filename not in self.indexes: + raise IOError(errno.ENOENT, 'the requested file {0} does not exist in the given Ren\'Py archive'.format( + _printable(filename))) + + # If it's in our opened archive index, and our archive handle isn't valid, something is obviously wrong. + if filename not in self.files and filename in self.indexes and self.handle is None: + raise IOError(errno.ENOENT, 'the requested file {0} does not exist in the given Ren\'Py archive'.format( + _printable(filename))) + + # Check our simplified internal indexes first, in case someone wants to read a file they added before without saving. + if filename in self.files: + self.verbose_print('Reading file {0} from internal storage...'.format(_printable(filename))) + return self.files[filename] + else: + # Read offset and length, seek to the offset and read the file contents. + if len(self.indexes[filename][0]) == 3: + (offset, length, prefix) = self.indexes[filename][0] + else: + (offset, length) = self.indexes[filename][0] + prefix = '' + + self.verbose_print('Reading file {0} from data file {1}... (offset = {2}, length = {3} bytes)'.format( + _printable(filename), self.file, offset, length)) + self.handle.seek(offset) + return _unmangle(prefix) + self.handle.read(length - len(prefix)) + + # Modify a file in archive or internal storage. + def change(self, filename, contents): + filename = _unicode(filename) + self.remove(filename) + self.add(filename, contents) + + # Add a file to the internal storage. + def add(self, filename, contents): + filename = self.convert_filename(_unicode(filename)) + if filename in self.files or filename in self.indexes: + raise ValueError('file {0} already exists in archive'.format(_printable(filename))) + self.verbose_print('Adding file {0} to archive... (length = {1} bytes)'.format( + _printable(filename), len(contents))) + self.files[filename] = contents + + # Remove a file from archive or internal storage. + def remove(self, filename): + filename = _unicode(filename) + if filename in self.files: + self.verbose_print('Removing file {0} from internal storage...'.format(_printable(filename))) + del self.files[filename] + elif filename in self.indexes: + self.verbose_print('Removing file {0} from archive indexes...'.format(_printable(filename))) + del self.indexes[filename] + else: + raise IOError(errno.ENOENT, 'the requested file {0} does not exist in this archive'.format(_printable(filename))) + + # Load archive. + def load(self, filename): + filename = _unicode(filename) + + if self.handle is not None: + self.handle.close() + self.file = filename + self.files = {} + self.handle = open(self.file, 'rb') + self.version = self.get_version() + self.indexes = self.extract_indexes() + + # Save current state into a new file, merging archive and internal storage, rebuilding indexes, and optionally saving in another format version. + def save(self, filename=None): + filename = _unicode(filename) + + if filename is None: + filename = self.file + if filename is None: + raise ValueError('no target file found for saving archive') + if self.version != 2 and self.version != 3: + raise ValueError('saving is only supported for version 2 and 3 archives') + + self.verbose_print('Rebuilding archive index...') + # Merge files added or changed in this session. + files = self.files + # First, read files from the current archive into our files structure. + for file in list(self.indexes.keys()): + content = self.read(file) + del self.indexes[file] + files[file] = content + + # Predict header length; we will write it last. + offset = 0 + if self.version == 3: + offset = 34 + elif self.version == 2: + offset = 25 + archive = open(filename, 'wb') + archive.seek(offset) + + # Build new indexes while writing files to the archive. + indexes = {} + self.verbose_print('Writing files to archive file...') + for file, content in files.items(): + # Generate random padding if needed. + if self.padlength > 0: + padding = self.generate_padding() + archive.write(padding) + offset += len(padding) + + archive.write(content) + # Update index. + if self.version == 3: + indexes[file] = [ (offset ^ self.key, len(content) ^ self.key) ] + elif self.version == 2: + indexes[file] = [ (offset, len(content)) ] + offset += len(content) + + # Write the indexes. + self.verbose_print('Writing archive index to archive file...') + archive.write(codecs.encode(pickle.dumps(indexes, self.PICKLE_PROTOCOL), 'zlib')) + # Now write the header. + self.verbose_print('Writing header to archive file... (version = RPAv{0})'.format(self.version)) + archive.seek(0) + if self.version == 3: + archive.write(codecs.encode('{}{:016x} {:08x}\n'.format(self.RPA3_MAGIC, offset, self.key))) + else: + archive.write(codecs.encode('{}{:016x}\n'.format(self.RPA2_MAGIC, offset))) + archive.close() + + # Reload the file in our internal database. + self.load(filename) + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description='A tool for working with Ren\'Py archive files.', + epilog='The FILE argument can optionally be in ARCHIVE=REAL format, mapping a file in the archive filesystem to a file on your system. For example: rpatool -x test.rpa script.rpyc=/home/foo/test.rpyc', + add_help=False) + + parser.add_argument('archive', metavar='ARCHIVE', nargs='?', help='The Ren\'Py archive file to operate on.') + parser.add_argument('files', metavar='FILE', nargs='*', action='append', help='Zero or more files to operate on.') + + parser.add_argument('-l', '--list', action='store_true', help='List files in archive ARCHIVE.') + parser.add_argument('-x', '--extract', action='store_true', help='Extract FILEs from ARCHIVE.') + parser.add_argument('-c', '--create', action='store_true', help='Create ARCHIVE from FILEs.') + parser.add_argument('-d', '--delete', action='store_true', help='Delete FILEs from ARCHIVE.') + parser.add_argument('-a', '--append', action='store_true', help='Append FILEs to ARCHIVE.') + + parser.add_argument('-2', '--two', action='store_true', help='Use the RPAv2 format for creating/appending archives.') + parser.add_argument('-3', '--three', action='store_true', help='Use the RPAv3 format for creating/appending archives (default).') + + parser.add_argument('-k', '--key', metavar='KEY', help='The obfuscation key used for creating RPAv3 archives, in hexadecimal (default: 0xDEADBEEF).') + parser.add_argument('-p', '--padding', metavar='COUNT', help='The maximum number of padding bytes to add between files (default: 0).') + parser.add_argument('-o', '--outfile', help='An alternative output archive file when appending or deleting from archives, or output directory when extracting.') + + # Add new flag for extracting all archives in the current directory. + parser.add_argument('--all', action='store_true', help='If specified, extracts all .rpa archives in the current directory.') + + parser.add_argument('-h', '--help', action='help', help='Print this help message and exit.') + parser.add_argument('-v', '--verbose', action='store_true', help='Show detailed messages during operations.') + parser.add_argument('-V', '--version', action='version', version='rpatool v0.8', help='Show version information.') + arguments = parser.parse_args() + + # Determine RPA version. + if arguments.two: + version = 2 + else: + version = 3 + + # Determine RPAv3 key. + if hasattr(arguments, 'key') and arguments.key is not None: + key = int(arguments.key, 16) + else: + key = 0xDEADBEEF + + # Determine padding bytes. + if hasattr(arguments, 'padding') and arguments.padding is not None: + padding = int(arguments.padding) + else: + padding = 0 + + # If the --all flag is specified, ignore the ARCHIVE argument and process all .rpa files in the current directory. + if arguments.all: + archive_files = [f for f in os.listdir('.') if f.lower().endswith('.rpa')] + if not archive_files: + print('No .rpa archives found in the current directory.', file=sys.stderr) + sys.exit(0) + for archive_filename in archive_files: + print("Processing archive: {}".format(archive_filename)) + try: + current_archive = RenPyArchive(archive_filename, padlength=padding, key=key, version=version, verbose=arguments.verbose) + except IOError as e: + print('Failed to open archive {}: {}'.format(archive_filename, e), file=sys.stderr) + continue + + # Determine the extraction directory. + # If --outfile is provided, use it; otherwise, extract to the current directory directly. + if arguments.outfile: + outfolder = _unicode(arguments.outfile) + else: + outfolder = "." + + if not os.path.exists(outfolder): + os.makedirs(outfolder) + + # Extract all files from the archive. + for filename in current_archive.list(): + if filename.find('=') != -1: + (outfile, filename) = filename.split('=', 2) + else: + outfile = filename + try: + contents = current_archive.read(filename) + destination = os.path.join(outfolder, outfile) + destdir = os.path.dirname(destination) + if destdir and not os.path.exists(destdir): + os.makedirs(destdir) + with open(destination, 'wb') as file_out: + file_out.write(contents) + except Exception as e: + print('Failed to extract file {} from {}: {}'.format(filename, archive_filename, e), file=sys.stderr) + sys.exit(0) + + # Determine input archive/output file. + if arguments.create: + archive = None + output = _unicode(arguments.archive) + else: + archive = _unicode(arguments.archive) if arguments.archive is not None else None + if hasattr(arguments, 'outfile') and arguments.outfile is not None: + output = _unicode(arguments.outfile) + else: + if arguments.extract: + output = '.' + else: + output = _unicode(arguments.archive) if arguments.archive is not None else '' + + # Normalize file arguments. + if len(arguments.files) > 0 and isinstance(arguments.files[0], list): + arguments.files = arguments.files[0] + + try: + if archive is None: + raise ValueError("No input archive specified.") + archive = RenPyArchive(archive, padlength=padding, key=key, version=version, verbose=arguments.verbose) + except Exception as e: + print('Failed to open archive {} for reading: {}'.format(archive, e), file=sys.stderr) + sys.exit(1) + + if arguments.create or arguments.append: + def add_file(filename): + if filename.find('=') != -1: + (outfile, filename) = filename.split('=', 2) + else: + outfile = filename + + if os.path.isdir(filename): + for file in os.listdir(filename): + add_file(outfile + os.sep + file + '=' + filename + os.sep + file) + else: + try: + with open(filename, 'rb') as file_in: + archive.add(outfile, file_in.read()) + except Exception as e: + print('Failed to add file {} to archive: {}'.format(filename, e), file=sys.stderr) + + for filename in arguments.files: + add_file(_unicode(filename)) + + archive.version = version + try: + archive.save(output) + except Exception as e: + print('Failed to save archive: {}'.format(e), file=sys.stderr) + elif arguments.delete: + for filename in arguments.files: + try: + archive.remove(filename) + except Exception as e: + print('Failed to delete file {} from archive: {}'.format(filename, e), file=sys.stderr) + archive.version = version + try: + archive.save(output) + except Exception as e: + print('Failed to save archive: {}'.format(e), file=sys.stderr) + elif arguments.extract: + # If specific files are provided, extract them; otherwise, extract all files. + if len(arguments.files) > 0: + files = arguments.files + else: + files = archive.list() + + if not os.path.exists(output): + os.makedirs(output) + + for filename in files: + if filename.find('=') != -1: + (outfile, filename) = filename.split('=', 2) + else: + outfile = filename + try: + contents = archive.read(filename) + out_path = os.path.join(output, outfile) + if os.path.dirname(out_path) and not os.path.exists(os.path.dirname(out_path)): + os.makedirs(os.path.dirname(out_path)) + with open(out_path, 'wb') as file_out: + file_out.write(contents) + except Exception as e: + print('Failed to extract file {} from archive: {}'.format(filename, e), file=sys.stderr) + elif arguments.list: + file_list = archive.list() + file_list.sort() + for file in file_list: + print(file) + else: + print('No operation specified :(') + print('Use {} --help for usage details.'.format(sys.argv[0])) + +if __name__ == "__main__": + main() \ No newline at end of file