Skip to content

Commit

Permalink
Merge 11b1ac2 into e5af0df
Browse files Browse the repository at this point in the history
  • Loading branch information
jmathai committed Jan 22, 2019
2 parents e5af0df + 11b1ac2 commit 5d1bddd
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 44 deletions.
22 changes: 22 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
204 changes: 160 additions & 44 deletions elodie/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -31,8 +36,13 @@ def __init__(self):
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.
Expand Down Expand Up @@ -100,10 +110,14 @@ def get_current_directory(self):
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`
Expand All @@ -116,42 +130,148 @@ def get_file_name(self, media):
if(metadata is None):
return None

# 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]
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(
'^\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(
'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)

file_name = '%s-%s.%s' % (
time.strftime(
'%Y-%m-%d_%H-%M-%S',
metadata['date_taken']
),
base_name,
metadata['extension'])
return file_name.lower()
# 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 metadata[part]:
this_value = os.path.splitext(metadata['original_name'])[0]
else:
# 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(this_value) == 0):
this_value = metadata['base_name']

# 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

# 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.
Expand Down Expand Up @@ -201,10 +321,6 @@ def get_folder_path_definition(self):
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('|'):
Expand All @@ -215,13 +331,14 @@ def get_folder_path_definition(self):

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
"""
path_parts = self.get_folder_path_definition()
if path_parts is None:
path_parts = self.get_folder_path_definition()
path = []
for path_part in path_parts:
# We support fallback values so that
Expand Down Expand Up @@ -295,7 +412,6 @@ def get_dynamic_path(self, part, mask, metadata):

return ''


def parse_mask_for_location(self, mask, location_parts, place_name):
"""Takes a mask for a location and interpolates the actual place names.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 94 additions & 0 deletions elodie/tests/filesystem_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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'))
Expand Down

0 comments on commit 5d1bddd

Please sign in to comment.