028b728d82
Moves to Python format strings for renaming, handles directory structures, moving of files to a destination directory, sanitizes file paths with pathvalidate and takes a different approach to smart filename cleanup using the Python string.Formatter class Moving to Python format strings means we can point to python documentation for syntax and all we have to do is document the properties and types that are attached to the GenericMetadata class. Switching to pathvalidate allows comictagger to more simply handle both directories and symbols in filenames. The only changes to the string.Formatter class is: 1. format_field returns an empty string if the value is none or an empty string regardless of the format specifier. 2. _vformat drops the previous literal text if the field value is an empty string and lstrips the following literal text of closing special characters.
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""Functions for renaming files based on metadata"""
|
|
|
|
# Copyright 2012-2014 Anthony Beville
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import string
|
|
|
|
from pathvalidate import sanitize_filepath
|
|
|
|
from comicapi.genericmetadata import GenericMetadata
|
|
from comicapi.issuestring import IssueString
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MetadataFormatter(string.Formatter):
|
|
def __init__(self, smart_cleanup=False):
|
|
super().__init__()
|
|
self.smart_cleanup = smart_cleanup
|
|
|
|
def format_field(self, value, format_spec):
|
|
if value is None or value == "":
|
|
return ""
|
|
return super().format_field(value, format_spec)
|
|
|
|
def _vformat(self, format_string, args, kwargs, used_args, recursion_depth, auto_arg_index=0):
|
|
if recursion_depth < 0:
|
|
raise ValueError("Max string recursion exceeded")
|
|
result = []
|
|
lstrip = False
|
|
for literal_text, field_name, format_spec, conversion in self.parse(format_string):
|
|
|
|
# output the literal text
|
|
if literal_text:
|
|
if lstrip:
|
|
result.append(literal_text.lstrip("-_)}]#"))
|
|
else:
|
|
result.append(literal_text)
|
|
lstrip = False
|
|
# if there's a field, output it
|
|
if field_name is not None:
|
|
# this is some markup, find the object and do
|
|
# the formatting
|
|
|
|
# handle arg indexing when empty field_names are given.
|
|
if field_name == "":
|
|
if auto_arg_index is False:
|
|
raise ValueError(
|
|
"cannot switch from manual field " "specification to automatic field " "numbering"
|
|
)
|
|
field_name = str(auto_arg_index)
|
|
auto_arg_index += 1
|
|
elif field_name.isdigit():
|
|
if auto_arg_index:
|
|
raise ValueError(
|
|
"cannot switch from manual field " "specification to automatic field " "numbering"
|
|
)
|
|
# disable auto arg incrementing, if it gets
|
|
# used later on, then an exception will be raised
|
|
auto_arg_index = False
|
|
|
|
# given the field_name, find the object it references
|
|
# and the argument it came from
|
|
obj, arg_used = self.get_field(field_name, args, kwargs)
|
|
used_args.add(arg_used)
|
|
|
|
# do any conversion on the resulting object
|
|
obj = self.convert_field(obj, conversion)
|
|
|
|
# expand the format spec, if needed
|
|
format_spec, auto_arg_index = self._vformat(
|
|
format_spec, args, kwargs, used_args, recursion_depth - 1, auto_arg_index=auto_arg_index
|
|
)
|
|
|
|
# format the object and append to the result
|
|
fmt_obj = self.format_field(obj, format_spec)
|
|
if fmt_obj == "" and len(result) > 0 and self.smart_cleanup:
|
|
lstrip = True
|
|
result.pop()
|
|
result.append(fmt_obj)
|
|
|
|
return "".join(result), auto_arg_index
|
|
|
|
|
|
class FileRenamer:
|
|
def __init__(self, metadata):
|
|
self.template = "{publisher}/{series}/{series} v{volume} #{issue} (of {issueCount}) ({year})"
|
|
self.smart_cleanup = True
|
|
self.issue_zero_padding = 3
|
|
self.metadata = metadata
|
|
self.move = False
|
|
|
|
def set_metadata(self, metadata: GenericMetadata):
|
|
self.metadata = metadata
|
|
|
|
def set_issue_zero_padding(self, count):
|
|
self.issue_zero_padding = count
|
|
|
|
def set_smart_cleanup(self, on):
|
|
self.smart_cleanup = on
|
|
|
|
def set_template(self, template: str):
|
|
self.template = template
|
|
|
|
def determine_name(self, filename, ext=None):
|
|
class Default(dict):
|
|
def __missing__(self, key):
|
|
return "{" + key + "}"
|
|
|
|
md = self.metadata
|
|
|
|
# padding for issue
|
|
md.issue = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
|
|
|
|
template = self.template
|
|
|
|
path_components = template.split(os.sep)
|
|
new_name = ""
|
|
|
|
fmt = MetadataFormatter(self.smart_cleanup)
|
|
for Component in path_components:
|
|
new_name = os.path.join(
|
|
new_name, fmt.vformat(Component, args=[], kwargs=Default(vars(md))).replace("/", "-")
|
|
)
|
|
|
|
if ext is None or ext == "":
|
|
ext = os.path.splitext(filename)[1]
|
|
|
|
new_name += ext
|
|
|
|
# some tweaks to keep various filesystems happy
|
|
new_name = new_name.replace(": ", " - ")
|
|
new_name = new_name.replace(":", "-")
|
|
|
|
# remove padding
|
|
md.issue = IssueString(md.issue).as_string()
|
|
if self.move:
|
|
return sanitize_filepath(new_name.strip())
|
|
else:
|
|
return os.path.basename(sanitize_filepath(new_name.strip()))
|