Add support for customizing file name via config file. #107 #110 #111 (#225)

This commit is contained in:
Jaisen Mathai 2019-01-23 01:06:48 +05:30 committed by GitHub
parent e5af0dfb4e
commit 5b07386e2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 274 additions and 42 deletions

View File

@ -247,6 +247,28 @@ In addition to my built-in and date placeholders you can combine them into a sin
* `%date` can be used to combine multiple values from [the standard Python time directives](https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior). For example, `date=%Y-%m` would result in folder names like `2015-12`.
* `%custom` can be used to combine multiple values from anything else. Think of it as a catch-all when `%location` and `%date` don't meet your needs.
#### How file customization works
You can configure how Elodie names your files using placeholders. This works similarly to how folder customization works. The default naming format is what's referred to elsewhere in this document and has many thought through benefits. Using the default will gives you files named like `2015-09-27_01-41-38-_dsc8705.jpg`.
* Minimizes the likelihood of naming conflicts.
* Encodes important EXIF information into the file name.
* Optimizes for sort order when listing in most file and photo viewers.
If you'd like to specify your own naming convention it's recommended you include something that's mostly unique like the time including seconds. You'll need to include a `[File]` section in your `config.ini` file with a name attribute. If a placeholder doesn't have a value then it plus any preceding characters which are not alphabetic are removed.
```
[File]
date=%Y-%m-%b-%H-%M-%S
name=%date-%original_name-%title.jpg
# -> 2012-05-Mar-12-59-30-dsc_1234-my-title.jpg
date=%Y-%m-%b-%H-%M-%S
name=%date-%original_name-%album.jpg
# -> 2012-05-Mar-12-59-30-dsc_1234-my-album.jpg
```
### Reorganize by changing location and dates
If you notice some photos were incorrectly organized you should definitely let me know. In the example above I put two photos into an *Unknown Location* folder because I didn't find GPS information in their EXIF. To fix this I'll help you add GPS information into the photos' EXIF and then I'll reorganize them.

View File

@ -23,6 +23,11 @@ class FileSystem(object):
"""A class for interacting with the file system."""
def __init__(self):
# The default folder path is along the lines of 2017-06-17_01-04-14-dsc_1234-some-title.jpg
self.default_file_name_definition = {
'date': '%Y-%m-%d_%H-%M-%S',
'name': '%date-%original_name-%title.%extension',
}
# The default folder path is along the lines of 2015-01-Jan/Chicago
self.default_folder_path_definition = {
'date': '%Y-%m-%b',
@ -31,8 +36,13 @@ class FileSystem(object):
geolocation.__DEFAULT_LOCATION__
),
}
self.cached_file_name_definition = None
self.cached_folder_path_definition = None
self.default_parts = ['album', 'city', 'state', 'country']
# Python3 treats the regex \s differently than Python2.
# It captures some additional characters like the unicode checkmark \u2713.
# See build failures in Python3 here.
# https://travis-ci.org/jmathai/elodie/builds/483012902
self.whitespace_regex = '[ \t\n\r\f\v]+'
def create_directory(self, directory_path):
"""Create a directory if it does not already exist.
@ -100,10 +110,14 @@ class FileSystem(object):
def get_file_name(self, media):
"""Generate file name for a photo or video using its metadata.
Originally we hardcoded the file name to include an ISO date format.
We use an ISO8601-like format for the file name prefix. Instead of
colons as the separator for hours, minutes and seconds we use a hyphen.
https://en.wikipedia.org/wiki/ISO_8601#General_principles
PR #225 made the file name customizable and fixed issues #107 #110 #111.
https://github.com/jmathai/elodie/pull/225
:param media: A Photo or Video instance
:type media: :class:`~elodie.media.photo.Photo` or
:class:`~elodie.media.video.Video`
@ -116,42 +130,148 @@ class FileSystem(object):
if(metadata is None):
return None
# Get the name template and definition.
# Name template is in the form %date-%original_name-%title.%extension
# Definition is in the form
# [
# [('date', '%Y-%m-%d_%H-%M-%S')],
# [('original_name', '')], [('title', '')], // contains a fallback
# [('extension', '')]
# ]
name_template, definition = self.get_file_name_definition()
name = name_template
for parts in definition:
this_value = None
for this_part in parts:
part, mask = this_part
if part in ('date', 'day', 'month', 'year'):
this_value = time.strftime(mask, metadata['date_taken'])
break
elif part in ('location', 'city', 'state', 'country'):
place_name = geolocation.place_name(
metadata['latitude'],
metadata['longitude']
)
location_parts = re.findall('(%[^%]+)', mask)
this_value = self.parse_mask_for_location(
mask,
location_parts,
place_name,
)
break
elif part in ('album', 'extension', 'title'):
if metadata[part]:
this_value = re.sub(self.whitespace_regex, '-', metadata[part].strip())
break
elif part in ('original_name'):
# First we check if we have metadata['original_name'].
# We have to do this for backwards compatibility because
# we original did not store this back into EXIF.
if('original_name' in metadata and metadata['original_name']):
base_name = os.path.splitext(metadata['original_name'])[0]
if metadata[part]:
this_value = os.path.splitext(metadata['original_name'])[0]
else:
# If the file has EXIF title we use that in the file name
# (i.e. my-favorite-photo-img_1234.jpg)
# We want to remove the date prefix we add to the name.
# This helps when re-running the program on file which were already
# processed.
base_name = re.sub(
# We didn't always store original_name so this is
# for backwards compatability.
# We want to remove the hardcoded date prefix we used
# to add to the name.
# This helps when re-running the program on file
# which were already processed.
this_value = re.sub(
'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-',
'',
metadata['base_name']
)
if(len(base_name) == 0):
base_name = metadata['base_name']
if(len(this_value) == 0):
this_value = metadata['base_name']
if(
'title' in metadata and
metadata['title'] is not None and
len(metadata['title']) > 0
):
title_sanitized = re.sub('\W+', '-', metadata['title'].strip())
base_name = base_name.replace('-%s' % title_sanitized, '')
base_name = '%s-%s' % (base_name, title_sanitized)
# Lastly we want to sanitize the name
this_value = re.sub(self.whitespace_regex, '-', this_value.strip())
elif part.startswith('"') and part.endswith('"'):
this_value = part[1:-1]
break
file_name = '%s-%s.%s' % (
time.strftime(
'%Y-%m-%d_%H-%M-%S',
metadata['date_taken']
),
base_name,
metadata['extension'])
return file_name.lower()
# Here we replace the placeholder with it's corresponding value.
# Check if this_value was not set so that the placeholder
# can be removed completely.
# For example, %title- will be replaced with ''
# Else replace the placeholder (i.e. %title) with the value.
if this_value is None:
name = re.sub(
#'[^a-z_]+%{}'.format(part),
'[^a-zA-Z0-9_]+%{}'.format(part),
'',
name,
)
else:
name = re.sub(
'%{}'.format(part),
this_value,
name,
)
return name.lower()
def get_file_name_definition(self):
"""Returns a list of folder definitions.
Each element in the list represents a folder.
Fallback folders are supported and are nested lists.
Return values take the following form.
[
('date', '%Y-%m-%d'),
[
('location', '%city'),
('album', ''),
('"Unknown Location", '')
]
]
:returns: list
"""
# If we've done this already then return it immediately without
# incurring any extra work
if self.cached_file_name_definition is not None:
return self.cached_file_name_definition
config = load_config()
# If File is in the config we assume name and its
# corresponding values are also present
config_file = self.default_file_name_definition
if('File' in config):
config_file = config['File']
# Find all subpatterns of name that map to the components of the file's
# name.
# I.e. %date-%original_name-%title.%extension => ['date', 'original_name', 'title', 'extension'] #noqa
path_parts = re.findall(
'(\%[a-z_]+)',
config_file['name']
)
if not path_parts or len(path_parts) == 0:
return (config_file['name'], self.default_file_name_definition)
self.cached_file_name_definition = []
for part in path_parts:
if part in config_file:
part = part[1:]
self.cached_file_name_definition.append(
[(part, config_file[part])]
)
else:
this_part = []
for p in part.split('|'):
p = p[1:]
this_part.append(
(p, config_file[p] if p in config_file else '')
)
self.cached_file_name_definition.append(this_part)
self.cached_file_name_definition = (config_file['name'], self.cached_file_name_definition)
return self.cached_file_name_definition
def get_folder_path_definition(self):
"""Returns a list of folder definitions.
@ -201,10 +321,6 @@ class FileSystem(object):
self.cached_folder_path_definition.append(
[(part, config_directory[part])]
)
elif part in self.default_parts:
self.cached_folder_path_definition.append(
[(part, '')]
)
else:
this_part = []
for p in part.split('|'):
@ -215,12 +331,13 @@ class FileSystem(object):
return self.cached_folder_path_definition
def get_folder_path(self, metadata):
def get_folder_path(self, metadata, path_parts=None):
"""Given a media's metadata this function returns the folder path as a string.
:param dict metadata: Metadata dictionary.
:returns: str
"""
if path_parts is None:
path_parts = self.get_folder_path_definition()
path = []
for path_part in path_parts:
@ -295,7 +412,6 @@ class FileSystem(object):
return ''
def parse_mask_for_location(self, mask, location_parts, place_name):
"""Takes a mask for a location and interpolates the actual place names.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -177,6 +177,33 @@ def test_get_current_directory():
filesystem = FileSystem()
assert os.getcwd() == filesystem.get_current_directory()
def test_get_file_name_definition_default():
filesystem = FileSystem()
name_template, definition = filesystem.get_file_name_definition()
assert name_template == '%date-%original_name-%title.%extension', name_template
assert definition == [[('date', '%Y-%m-%d_%H-%M-%S')], [('original_name', '')], [('title', '')], [('extension', '')]], definition #noqa
@mock.patch('elodie.config.config_file', '%s/config.ini-custom-filename' % gettempdir())
def test_get_file_name_definition_custom():
with open('%s/config.ini-custom-filename' % gettempdir(), 'w') as f:
f.write("""
[File]
date=%Y-%m-%b
name=%date-%original_name.%extension
""")
if hasattr(load_config, 'config'):
del load_config.config
filesystem = FileSystem()
name_template, definition = filesystem.get_file_name_definition()
if hasattr(load_config, 'config'):
del load_config.config
assert name_template == '%date-%original_name.%extension', name_template
assert definition == [[('date', '%Y-%m-%b')], [('original_name', '')], [('extension', '')]], definition #noqa
def test_get_file_name_plain():
filesystem = FileSystem()
media = Photo(helper.get_file('plain.jpg'))
@ -205,6 +232,73 @@ def test_get_file_name_with_original_name_title_exif():
assert file_name == helper.path_tz_fix('2015-12-05_00-59-26-foobar-foobar-title.jpg'), file_name
def test_get_file_name_with_uppercase_and_spaces():
filesystem = FileSystem()
media = Photo(helper.get_file('Plain With Spaces And Uppercase 123.jpg'))
file_name = filesystem.get_file_name(media)
assert file_name == helper.path_tz_fix('2015-12-05_00-59-26-plain-with-spaces-and-uppercase-123.jpg'), file_name
@mock.patch('elodie.config.config_file', '%s/config.ini-filename-custom' % gettempdir())
def test_get_file_name_custom():
with open('%s/config.ini-filename-custom' % gettempdir(), 'w') as f:
f.write("""
[File]
date=%Y-%m-%b
name=%date-%original_name.%extension
""")
if hasattr(load_config, 'config'):
del load_config.config
filesystem = FileSystem()
media = Photo(helper.get_file('plain.jpg'))
file_name = filesystem.get_file_name(media)
if hasattr(load_config, 'config'):
del load_config.config
assert file_name == helper.path_tz_fix('2015-12-dec-plain.jpg'), file_name
@mock.patch('elodie.config.config_file', '%s/config.ini-filename-custom-with-title' % gettempdir())
def test_get_file_name_custom_with_title():
with open('%s/config.ini-filename-custom-with-title' % gettempdir(), 'w') as f:
f.write("""
[File]
date=%Y-%m-%d
name=%date-%original_name-%title.%extension
""")
if hasattr(load_config, 'config'):
del load_config.config
filesystem = FileSystem()
media = Photo(helper.get_file('with-title.jpg'))
file_name = filesystem.get_file_name(media)
if hasattr(load_config, 'config'):
del load_config.config
assert file_name == helper.path_tz_fix('2015-12-05-with-title-some-title.jpg'), file_name
@mock.patch('elodie.config.config_file', '%s/config.ini-filename-custom-with-empty-value' % gettempdir())
def test_get_file_name_custom_with_empty_value():
with open('%s/config.ini-filename-custom-with-empty-value' % gettempdir(), 'w') as f:
f.write("""
[File]
date=%Y-%m-%d
name=%date-%original_name-%title.%extension
""")
if hasattr(load_config, 'config'):
del load_config.config
filesystem = FileSystem()
media = Photo(helper.get_file('plain.jpg'))
file_name = filesystem.get_file_name(media)
if hasattr(load_config, 'config'):
del load_config.config
assert file_name == helper.path_tz_fix('2015-12-05-plain.jpg'), file_name
def test_get_folder_path_plain():
filesystem = FileSystem()
media = Photo(helper.get_file('plain.jpg'))