* Use original name from EXIF instead of parsing assumed file name format. #107 * Updates to docs and code
This commit is contained in:
parent
f7be8f323f
commit
74d8675b20
71
Readme.md
71
Readme.md
|
@ -122,7 +122,8 @@ I work tirelessly to make sure your photos are always sorted and organized so yo
|
||||||
You don't love me yet but you will.
|
You don't love me yet but you will.
|
||||||
|
|
||||||
I only do 3 things.
|
I only do 3 things.
|
||||||
* Firstly I organize your existing collection of photos.
|
|
||||||
|
* Firstly I organize your existing collection of photos into a customizable folder structure.
|
||||||
* Second I help make it easy for all the photos you haven't taken yet to flow into the exact location they belong.
|
* Second I help make it easy for all the photos you haven't taken yet to flow into the exact location they belong.
|
||||||
* Third but not least I promise to do all this without a yucky propietary database that some friends of mine use.
|
* Third but not least I promise to do all this without a yucky propietary database that some friends of mine use.
|
||||||
|
|
||||||
|
@ -152,16 +153,15 @@ Updating EXIF of photos from the command line.
|
||||||
|
|
||||||
I'm most helpful when I'm fully utilized to keep your photos organized.
|
I'm most helpful when I'm fully utilized to keep your photos organized.
|
||||||
|
|
||||||
Here's an example of a very asynchronous setup.
|
Here's an example of how I can create 3 geographically distributed copies of your meticulously organized photo library.
|
||||||
|
|
||||||
* Specify a folder in your Dropbox/Google Drive to store the organized photo library.
|
* Specify a folder in your Dropbox/Google Drive to store the organized photo library.
|
||||||
* Set up a Hazel rule to notify me when photos arrive in `~/Downloads` so I can import them.
|
* Set up a cron job to import photos in `~/Ready-To-Upload`.
|
||||||
* The rule waits 1 minute before processing the photo which gives you a chance to move it elsewhere if it's not something you want in the library.
|
* Add photos to `~/Ready-To-Upload` and wait for your cron job to trigger.
|
||||||
* Use AirDrop to transfer files from any iPhone to your laptop. That goes to `~/Downloads` for the Hazel rule to process.
|
|
||||||
* AirDrop is fast, easy for anyone to use and once the transfer is finished your don't have to stick around. I'll move it to Dropbox/Google Drive and Dropbox/Google Drive will sync it to their servers.
|
|
||||||
* Periodically recategorize photos by fixing their location or date or by adding them to an album.
|
* Periodically recategorize photos by fixing their location or date or by adding them to an album.
|
||||||
* Have a Synology at home set to automatically sync down from Dropbox/Google Drive.
|
* Have a Synology at home set to automatically sync down from Dropbox/Google Drive.
|
||||||
|
|
||||||
This setup means you can quickly get photos off your or anyone's phone and know that they'll be organized and backed up in 3 locations by the time you're ready to view them.
|
This setup means you can quickly get photos off your phone or dSLR and know that they'll be organized and backed up in 3 locations by the time you're ready to view or share them.
|
||||||
|
|
||||||
<p align="center"><img src ="creative/workflow-simplified-white-bg.png" /></p>
|
<p align="center"><img src ="creative/workflow-simplified-white-bg.png" /></p>
|
||||||
|
|
||||||
|
@ -209,33 +209,60 @@ OK, so what if you don't like the folders being named `2015-07-Jul/Mountain View
|
||||||
|
|
||||||
You can add a custom folder structure by editing your `config.ini` file. This is what I include in the sample config file.
|
You can add a custom folder structure by editing your `config.ini` file. This is what I include in the sample config file.
|
||||||
|
|
||||||
|
#### Custom folder examples
|
||||||
|
|
||||||
|
Sometimes examples are easier to understand than explainations so I'll start there. If you'd like to understand my magic I explain it in more detail below these examples. You customize your folder structure in the `Directory` section of your `config.ini`.
|
||||||
|
|
||||||
```
|
```
|
||||||
[Directory]
|
location=%city, %state
|
||||||
date=%Y-%m-%b
|
year=%Y
|
||||||
location=%city
|
full_path=%year/%location
|
||||||
|
|
||||||
|
# 2015/Sunnyvale, California
|
||||||
|
|
||||||
|
location=%city, %state
|
||||||
|
month=%B
|
||||||
|
year=%Y
|
||||||
|
full_path=%year/%month/%location
|
||||||
|
|
||||||
|
# 2015/December/Sunnyvale, California
|
||||||
|
|
||||||
|
location=%city, %state
|
||||||
|
month=%m
|
||||||
|
year=%Y
|
||||||
|
date=%year-%month
|
||||||
full_path=%date/%location
|
full_path=%date/%location
|
||||||
|
|
||||||
|
# 2015-12/Sunnyvale, California
|
||||||
|
|
||||||
|
full_path=%country/%state/%city
|
||||||
|
|
||||||
|
# US/California/Sunnyvale
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There needs to be 2 levels of folders and you can construct them using the date and location. Use `full_path` to determine how the 2 levels are nested. If for some reason your config is not correct I will use the default formatting which is found in `config.ini-sample`.
|
#### How folder customization works
|
||||||
|
|
||||||
The default formatting from the above config looks like `2015-07-Jul/Mountain View`.
|
You can construct your folder structure using a combination of the location and dates. Under the `Directory` section of your `config.ini` file you can define placeholder names and assign each a value. For example, `date=%Y-%m` would create a date placeholder with a value of YYYY-MM which would be filled in with the date from the EXIF on the photo.
|
||||||
|
|
||||||
#### Customizing the date format
|
The placeholders can be used to define the folder structure you'd like to create. The example above happens to be the default structure and would look like `2015-07-Jul/Mountain View`.
|
||||||
|
|
||||||
You can use any of [the standard Python time directives](https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior) to create your ideal structure.
|
I have a few built-in location placeholders you can use.
|
||||||
|
|
||||||
* To have `201601`, use `date=%Y%m`
|
* `%city` the name of the city the photo was taken. Requires geolocation data in EXIF.
|
||||||
* For `Sunday, 01 January 2016`, use `date=%A, %d %B %Y`
|
* `%state` the name of the state the photo was taken. Requires geolocation data in EXIF.
|
||||||
* Python also has some pre-built formats. So you can get `Sun Jan 01 12:34:56 2016`, by using `%c`
|
* `%country` the name of the country the photo was taken. Requires geolocation data in EXIF.
|
||||||
|
|
||||||
#### Customizing the location format
|
I also have some date placeholders you can customize. You can use any of [the standard Python time directives](https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior) to customize the date format to your liking.
|
||||||
|
|
||||||
I use the [Open Street Maps Nominatim reverse geocoding API](http://wiki.openstreetmap.org/wiki/Nominatim#Example) provided by MapQuest. You can use `city`, `state` and `country` to construct the folder name.
|
* `%day` the day the photo was taken.
|
||||||
|
* `%month` the month the photo was taken.
|
||||||
|
* `%year` the year the photo was taken.
|
||||||
|
|
||||||
* To have `Sunnyvale`, use `location=%city`
|
In addition to my built-in and date placeholders you can combine them into a single folder name using my complex placeholders.
|
||||||
* To have `Sunnyvale-CA`, use `location=%city-%state
|
|
||||||
|
|
||||||
Sometimes a location may not have all of the values available. If your format is `%city-%state` and `city` was not returned then the folder name will be `%state`. Take note that I'll strip out extra characters so you don't end up with folders name `-%state` when `city` is not found.
|
* `%location` can be used to combine multiple values of `%city`, `%state` and `%country`. For example, `location=%city, %state` would result in folder names like `Sunnyvale, California`.
|
||||||
|
* `%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`.
|
||||||
|
|
||||||
### Reorganize by changing location and dates
|
### Reorganize by changing location and dates
|
||||||
|
|
||||||
|
|
|
@ -26,8 +26,15 @@ def _decode(string, encoding=sys.getfilesystemencoding()):
|
||||||
|
|
||||||
|
|
||||||
def _copyfile(src, dst):
|
def _copyfile(src, dst):
|
||||||
# Python 3 hangs using open/write method
|
# shutil.copy seems slow, changing to streaming according to
|
||||||
|
# http://stackoverflow.com/questions/22078621/python-how-to-copy-files-fast # noqa
|
||||||
|
# Python 3 hangs using open/write method so we proceed with shutil.copy
|
||||||
|
# and only perform the optimized write for Python 2.
|
||||||
if (constants.python_version == 3):
|
if (constants.python_version == 3):
|
||||||
|
# Do not use copy2(), it will have an issue when copying to a
|
||||||
|
# network/mounted drive.
|
||||||
|
# Using copy and manual set_date_from_filename gets the job done.
|
||||||
|
# The calling function is responsible for setting the time.
|
||||||
shutil.copy(src, dst)
|
shutil.copy(src, dst)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -114,7 +114,7 @@ class FileSystem(object):
|
||||||
# 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(metadata['original_name'] is not None):
|
if('original_name' in metadata and metadata['original_name']):
|
||||||
base_name = os.path.splitext(metadata['original_name'])[0]
|
base_name = os.path.splitext(metadata['original_name'])[0]
|
||||||
else:
|
else:
|
||||||
# If the file has EXIF title we use that in the file name
|
# If the file has EXIF title we use that in the file name
|
||||||
|
@ -163,18 +163,18 @@ class FileSystem(object):
|
||||||
|
|
||||||
config_directory = config['Directory']
|
config_directory = config['Directory']
|
||||||
|
|
||||||
path_parts = re.search(
|
# Find all subpatterns of full_path that map to directories.
|
||||||
'\%([^/]+)\/\%([^/]+)',
|
# I.e. %foo/%bar => ['foo', 'bar']
|
||||||
|
path_parts = re.findall(
|
||||||
|
'\%([a-z]+)',
|
||||||
config_directory['full_path']
|
config_directory['full_path']
|
||||||
)
|
)
|
||||||
|
|
||||||
if not path_parts or len(path_parts.groups()) != 2:
|
if not path_parts or len(path_parts) == 0:
|
||||||
return self.default_folder_path_definition
|
return self.default_folder_path_definition
|
||||||
|
|
||||||
path_part_groups = path_parts.groups()
|
|
||||||
self.cached_folder_path_definition = [
|
self.cached_folder_path_definition = [
|
||||||
(path_part_groups[0], config_directory[path_part_groups[0]]),
|
(part, config_directory[part]) for part in path_parts
|
||||||
(path_part_groups[1], config_directory[path_part_groups[1]]),
|
|
||||||
]
|
]
|
||||||
return self.cached_folder_path_definition
|
return self.cached_folder_path_definition
|
||||||
|
|
||||||
|
@ -188,25 +188,21 @@ class FileSystem(object):
|
||||||
path = []
|
path = []
|
||||||
for path_part in path_parts:
|
for path_part in path_parts:
|
||||||
part, mask = path_part
|
part, mask = path_part
|
||||||
if part == 'date':
|
if part in ('date', 'day', 'month', 'year'):
|
||||||
path.append(time.strftime(mask, metadata['date_taken']))
|
path.append(time.strftime(mask, metadata['date_taken']))
|
||||||
elif part == 'location':
|
elif part in ('location', 'city', 'state', 'country'):
|
||||||
if(
|
place_name = geolocation.place_name(
|
||||||
metadata['latitude'] is not None and
|
metadata['latitude'],
|
||||||
metadata['longitude'] is not None
|
metadata['longitude']
|
||||||
):
|
)
|
||||||
place_name = geolocation.place_name(
|
|
||||||
metadata['latitude'],
|
location_parts = re.findall('(%[^%]+)', mask)
|
||||||
metadata['longitude']
|
parsed_folder_name = self.parse_mask_for_location(
|
||||||
)
|
mask,
|
||||||
if(place_name is not None):
|
location_parts,
|
||||||
location_parts = re.findall('(%[^%]+)', mask)
|
place_name,
|
||||||
parsed_folder_name = self.parse_mask_for_location(
|
)
|
||||||
mask,
|
path.append(parsed_folder_name)
|
||||||
location_parts,
|
|
||||||
place_name,
|
|
||||||
)
|
|
||||||
path.append(parsed_folder_name)
|
|
||||||
|
|
||||||
# For now we always make the leaf folder an album if it's in the EXIF.
|
# For now we always make the leaf folder an album if it's in the EXIF.
|
||||||
# This is to preserve backwards compatability until we figure out how
|
# This is to preserve backwards compatability until we figure out how
|
||||||
|
@ -217,11 +213,6 @@ class FileSystem(object):
|
||||||
elif(len(path) == 2):
|
elif(len(path) == 2):
|
||||||
path[1] = metadata['album']
|
path[1] = metadata['album']
|
||||||
|
|
||||||
# if we don't have a 2nd level directory we use 'Unknown Location'
|
|
||||||
if(len(path) < 2):
|
|
||||||
path.append('Unknown Location')
|
|
||||||
|
|
||||||
# return '/'.join(path[::-1])
|
|
||||||
return os.path.join(*path)
|
return os.path.join(*path)
|
||||||
|
|
||||||
def parse_mask_for_location(self, mask, location_parts, place_name):
|
def parse_mask_for_location(self, mask, location_parts, place_name):
|
||||||
|
@ -338,11 +329,6 @@ class FileSystem(object):
|
||||||
shutil.move(_file, dest_path)
|
shutil.move(_file, dest_path)
|
||||||
os.utime(dest_path, (stat.st_atime, stat.st_mtime))
|
os.utime(dest_path, (stat.st_atime, stat.st_mtime))
|
||||||
else:
|
else:
|
||||||
# Do not use copy2(), will have an issue when copying to a
|
|
||||||
# network/mounted drive using copy and manual
|
|
||||||
# set_date_from_filename gets the job done
|
|
||||||
# shutil.copy seems slow, changing to streaming according to
|
|
||||||
# http://stackoverflow.com/questions/22078621/python-how-to-copy-files-fast # noqa
|
|
||||||
compatability._copyfile(_file, dest_path)
|
compatability._copyfile(_file, dest_path)
|
||||||
self.set_utime(media)
|
self.set_utime(media)
|
||||||
|
|
||||||
|
@ -352,7 +338,7 @@ class FileSystem(object):
|
||||||
return dest_path
|
return dest_path
|
||||||
|
|
||||||
def set_utime(self, media):
|
def set_utime(self, media):
|
||||||
""" Set the modification time on the file base on the file name.
|
""" Set the modification time on the file based on the file name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Initialize date taken to what's returned from the metadata function.
|
# Initialize date taken to what's returned from the metadata function.
|
||||||
|
|
|
@ -19,6 +19,7 @@ from elodie import log
|
||||||
from elodie.localstorage import Db
|
from elodie.localstorage import Db
|
||||||
|
|
||||||
__KEY__ = None
|
__KEY__ = None
|
||||||
|
__DEFAULT_LOCATION__ = 'Unknown Location'
|
||||||
|
|
||||||
|
|
||||||
def coordinates_by_name(name):
|
def coordinates_by_name(name):
|
||||||
|
@ -115,10 +116,14 @@ def get_key():
|
||||||
|
|
||||||
|
|
||||||
def place_name(lat, lon):
|
def place_name(lat, lon):
|
||||||
|
lookup_place_name_default = {'default': __DEFAULT_LOCATION__}
|
||||||
|
if(lat is None or lon is None):
|
||||||
|
return lookup_place_name_default
|
||||||
|
|
||||||
# Convert lat/lon to floats
|
# Convert lat/lon to floats
|
||||||
if not isinstance(lat, float):
|
if(not isinstance(lat, float)):
|
||||||
lat = float(lat)
|
lat = float(lat)
|
||||||
if not isinstance(lon, float):
|
if(not isinstance(lon, float)):
|
||||||
lon = float(lon)
|
lon = float(lon)
|
||||||
|
|
||||||
# Try to get cached location first
|
# Try to get cached location first
|
||||||
|
@ -132,19 +137,18 @@ def place_name(lat, lon):
|
||||||
|
|
||||||
lookup_place_name = {}
|
lookup_place_name = {}
|
||||||
geolocation_info = lookup(lat=lat, lon=lon)
|
geolocation_info = lookup(lat=lat, lon=lon)
|
||||||
if(geolocation_info is not None):
|
if(geolocation_info is not None and 'address' in geolocation_info):
|
||||||
if('address' in geolocation_info):
|
address = geolocation_info['address']
|
||||||
address = geolocation_info['address']
|
for loc in ['city', 'state', 'country']:
|
||||||
for loc in ['city', 'state', 'country']:
|
if(loc in address):
|
||||||
if(loc in address):
|
lookup_place_name[loc] = address[loc]
|
||||||
lookup_place_name[loc] = address[loc]
|
# In many cases the desired key is not available so we
|
||||||
# In many cases the desired key is not available so we
|
# set the most specific as the default.
|
||||||
# set the most specific as the default.
|
if('default' not in lookup_place_name):
|
||||||
if('default' not in lookup_place_name):
|
lookup_place_name['default'] = address[loc]
|
||||||
lookup_place_name['default'] = address[loc]
|
|
||||||
|
|
||||||
if('default' not in lookup_place_name):
|
if('default' not in lookup_place_name):
|
||||||
lookup_place_name = {'default': 'Unknown Location'}
|
lookup_place_name = lookup_place_name_default
|
||||||
|
|
||||||
if(lookup_place_name is not {}):
|
if(lookup_place_name is not {}):
|
||||||
db.add_location(lat, lon, lookup_place_name)
|
db.add_location(lat, lon, lookup_place_name)
|
||||||
|
|
|
@ -248,6 +248,48 @@ full_path=%date/%location
|
||||||
|
|
||||||
assert path == os.path.join('2015-12-05','United States of America-California-Sunnyvale'), path
|
assert path == os.path.join('2015-12-05','United States of America-California-Sunnyvale'), path
|
||||||
|
|
||||||
|
@mock.patch('elodie.config.config_file', '%s/config.ini-location-date' % gettempdir())
|
||||||
|
def test_get_folder_path_with_with_more_than_two_levels():
|
||||||
|
with open('%s/config.ini-location-date' % gettempdir(), 'w') as f:
|
||||||
|
f.write("""
|
||||||
|
[Directory]
|
||||||
|
year=%Y
|
||||||
|
month=%m
|
||||||
|
location=%city, %state
|
||||||
|
full_path=%year/%month/%location
|
||||||
|
""")
|
||||||
|
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
|
||||||
|
filesystem = FileSystem()
|
||||||
|
media = Photo(helper.get_file('with-location.jpg'))
|
||||||
|
path = filesystem.get_folder_path(media.get_metadata())
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
|
||||||
|
assert path == os.path.join('2015','12','Sunnyvale, California'), path
|
||||||
|
|
||||||
|
@mock.patch('elodie.config.config_file', '%s/config.ini-location-date' % gettempdir())
|
||||||
|
def test_get_folder_path_with_with_only_one_level():
|
||||||
|
with open('%s/config.ini-location-date' % gettempdir(), 'w') as f:
|
||||||
|
f.write("""
|
||||||
|
[Directory]
|
||||||
|
year=%Y
|
||||||
|
full_path=%year
|
||||||
|
""")
|
||||||
|
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
|
||||||
|
filesystem = FileSystem()
|
||||||
|
media = Photo(helper.get_file('plain.jpg'))
|
||||||
|
path = filesystem.get_folder_path(media.get_metadata())
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
|
||||||
|
assert path == os.path.join('2015'), path
|
||||||
|
|
||||||
def test_get_folder_path_with_location_and_title():
|
def test_get_folder_path_with_location_and_title():
|
||||||
filesystem = FileSystem()
|
filesystem = FileSystem()
|
||||||
media = Photo(helper.get_file('with-location-and-title.jpg'))
|
media = Photo(helper.get_file('with-location-and-title.jpg'))
|
||||||
|
@ -658,3 +700,47 @@ full_path=%date/%location
|
||||||
]
|
]
|
||||||
if hasattr(load_config, 'config'):
|
if hasattr(load_config, 'config'):
|
||||||
del load_config.config
|
del load_config.config
|
||||||
|
|
||||||
|
@mock.patch('elodie.config.config_file', '%s/config.ini-location-date' % gettempdir())
|
||||||
|
def test_get_folder_path_definition_with_more_than_two_levels():
|
||||||
|
with open('%s/config.ini-location-date' % gettempdir(), 'w') as f:
|
||||||
|
f.write("""
|
||||||
|
[Directory]
|
||||||
|
year=%Y
|
||||||
|
month=%m
|
||||||
|
day=%d
|
||||||
|
full_path=%year/%month/%day
|
||||||
|
""")
|
||||||
|
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
filesystem = FileSystem()
|
||||||
|
path_definition = filesystem.get_folder_path_definition()
|
||||||
|
expected = [
|
||||||
|
('year', '%Y'), ('month', '%m'), ('day', '%d')
|
||||||
|
]
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
|
||||||
|
assert path_definition == expected, path_definition
|
||||||
|
|
||||||
|
@mock.patch('elodie.config.config_file', '%s/config.ini-location-date' % gettempdir())
|
||||||
|
def test_get_folder_path_definition_with_only_one_level():
|
||||||
|
with open('%s/config.ini-location-date' % gettempdir(), 'w') as f:
|
||||||
|
f.write("""
|
||||||
|
[Directory]
|
||||||
|
year=%Y
|
||||||
|
full_path=%year
|
||||||
|
""")
|
||||||
|
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
filesystem = FileSystem()
|
||||||
|
path_definition = filesystem.get_folder_path_definition()
|
||||||
|
expected = [
|
||||||
|
('year', '%Y')
|
||||||
|
]
|
||||||
|
if hasattr(load_config, 'config'):
|
||||||
|
del load_config.config
|
||||||
|
|
||||||
|
assert path_definition == expected, path_definition
|
||||||
|
|
Loading…
Reference in New Issue