This commit is contained in:
parent
e5af0dfb4e
commit
5b07386e2c
22
Readme.md
22
Readme.md
|
@ -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`.
|
* `%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.
|
* `%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
|
### 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.
|
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.
|
||||||
|
|
|
@ -23,6 +23,11 @@ class FileSystem(object):
|
||||||
"""A class for interacting with the file system."""
|
"""A class for interacting with the file system."""
|
||||||
|
|
||||||
def __init__(self):
|
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
|
# The default folder path is along the lines of 2015-01-Jan/Chicago
|
||||||
self.default_folder_path_definition = {
|
self.default_folder_path_definition = {
|
||||||
'date': '%Y-%m-%b',
|
'date': '%Y-%m-%b',
|
||||||
|
@ -31,8 +36,13 @@ class FileSystem(object):
|
||||||
geolocation.__DEFAULT_LOCATION__
|
geolocation.__DEFAULT_LOCATION__
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
self.cached_file_name_definition = None
|
||||||
self.cached_folder_path_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):
|
def create_directory(self, directory_path):
|
||||||
"""Create a directory if it does not already exist.
|
"""Create a directory if it does not already exist.
|
||||||
|
@ -100,10 +110,14 @@ class FileSystem(object):
|
||||||
def get_file_name(self, media):
|
def get_file_name(self, media):
|
||||||
"""Generate file name for a photo or video using its metadata.
|
"""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
|
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.
|
colons as the separator for hours, minutes and seconds we use a hyphen.
|
||||||
https://en.wikipedia.org/wiki/ISO_8601#General_principles
|
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
|
:param media: A Photo or Video instance
|
||||||
:type media: :class:`~elodie.media.photo.Photo` or
|
:type media: :class:`~elodie.media.photo.Photo` or
|
||||||
:class:`~elodie.media.video.Video`
|
:class:`~elodie.media.video.Video`
|
||||||
|
@ -116,42 +130,148 @@ class FileSystem(object):
|
||||||
if(metadata is None):
|
if(metadata is None):
|
||||||
return 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'].
|
# First we check if we have metadata['original_name'].
|
||||||
# We have to do this for backwards compatibility because
|
# We have to do this for backwards compatibility because
|
||||||
# we original did not store this back into EXIF.
|
# we original did not store this back into EXIF.
|
||||||
if('original_name' in metadata and metadata['original_name']):
|
if metadata[part]:
|
||||||
base_name = os.path.splitext(metadata['original_name'])[0]
|
this_value = os.path.splitext(metadata['original_name'])[0]
|
||||||
else:
|
else:
|
||||||
# If the file has EXIF title we use that in the file name
|
# We didn't always store original_name so this is
|
||||||
# (i.e. my-favorite-photo-img_1234.jpg)
|
# for backwards compatability.
|
||||||
# We want to remove the date prefix we add to the name.
|
# We want to remove the hardcoded date prefix we used
|
||||||
# This helps when re-running the program on file which were already
|
# to add to the name.
|
||||||
# processed.
|
# This helps when re-running the program on file
|
||||||
base_name = re.sub(
|
# which were already processed.
|
||||||
|
this_value = re.sub(
|
||||||
'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-',
|
'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-',
|
||||||
'',
|
'',
|
||||||
metadata['base_name']
|
metadata['base_name']
|
||||||
)
|
)
|
||||||
if(len(base_name) == 0):
|
if(len(this_value) == 0):
|
||||||
base_name = metadata['base_name']
|
this_value = metadata['base_name']
|
||||||
|
|
||||||
if(
|
# Lastly we want to sanitize the name
|
||||||
'title' in metadata and
|
this_value = re.sub(self.whitespace_regex, '-', this_value.strip())
|
||||||
metadata['title'] is not None and
|
elif part.startswith('"') and part.endswith('"'):
|
||||||
len(metadata['title']) > 0
|
this_value = part[1:-1]
|
||||||
):
|
break
|
||||||
title_sanitized = re.sub('\W+', '-', metadata['title'].strip())
|
|
||||||
base_name = base_name.replace('-%s' % title_sanitized, '')
|
|
||||||
base_name = '%s-%s' % (base_name, title_sanitized)
|
|
||||||
|
|
||||||
file_name = '%s-%s.%s' % (
|
# Here we replace the placeholder with it's corresponding value.
|
||||||
time.strftime(
|
# Check if this_value was not set so that the placeholder
|
||||||
'%Y-%m-%d_%H-%M-%S',
|
# can be removed completely.
|
||||||
metadata['date_taken']
|
# For example, %title- will be replaced with ''
|
||||||
),
|
# Else replace the placeholder (i.e. %title) with the value.
|
||||||
base_name,
|
if this_value is None:
|
||||||
metadata['extension'])
|
name = re.sub(
|
||||||
return file_name.lower()
|
#'[^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):
|
def get_folder_path_definition(self):
|
||||||
"""Returns a list of folder definitions.
|
"""Returns a list of folder definitions.
|
||||||
|
@ -201,10 +321,6 @@ class FileSystem(object):
|
||||||
self.cached_folder_path_definition.append(
|
self.cached_folder_path_definition.append(
|
||||||
[(part, config_directory[part])]
|
[(part, config_directory[part])]
|
||||||
)
|
)
|
||||||
elif part in self.default_parts:
|
|
||||||
self.cached_folder_path_definition.append(
|
|
||||||
[(part, '')]
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
this_part = []
|
this_part = []
|
||||||
for p in part.split('|'):
|
for p in part.split('|'):
|
||||||
|
@ -215,12 +331,13 @@ class FileSystem(object):
|
||||||
|
|
||||||
return self.cached_folder_path_definition
|
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.
|
"""Given a media's metadata this function returns the folder path as a string.
|
||||||
|
|
||||||
:param dict metadata: Metadata dictionary.
|
:param dict metadata: Metadata dictionary.
|
||||||
:returns: str
|
:returns: str
|
||||||
"""
|
"""
|
||||||
|
if path_parts is None:
|
||||||
path_parts = self.get_folder_path_definition()
|
path_parts = self.get_folder_path_definition()
|
||||||
path = []
|
path = []
|
||||||
for path_part in path_parts:
|
for path_part in path_parts:
|
||||||
|
@ -295,7 +412,6 @@ class FileSystem(object):
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def parse_mask_for_location(self, mask, location_parts, place_name):
|
def parse_mask_for_location(self, mask, location_parts, place_name):
|
||||||
"""Takes a mask for a location and interpolates the actual place names.
|
"""Takes a mask for a location and interpolates the actual place names.
|
||||||
|
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
|
@ -177,6 +177,33 @@ def test_get_current_directory():
|
||||||
filesystem = FileSystem()
|
filesystem = FileSystem()
|
||||||
assert os.getcwd() == filesystem.get_current_directory()
|
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():
|
def test_get_file_name_plain():
|
||||||
filesystem = FileSystem()
|
filesystem = FileSystem()
|
||||||
media = Photo(helper.get_file('plain.jpg'))
|
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
|
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():
|
def test_get_folder_path_plain():
|
||||||
filesystem = FileSystem()
|
filesystem = FileSystem()
|
||||||
media = Photo(helper.get_file('plain.jpg'))
|
media = Photo(helper.get_file('plain.jpg'))
|
||||||
|
|
Loading…
Reference in New Issue