Change file paths definitions and parsing
This commit is contained in:
parent
b4a8cc88cb
commit
9f6eb52ebc
|
@ -1,16 +1,16 @@
|
|||
[Directory]
|
||||
[Path]
|
||||
# day_begins: what hour of the day you want the day to begin (only for
|
||||
# classification purposes). Defaults at 0 as midnight. Can be
|
||||
# used to group early morning photos with the previous day. Must
|
||||
# be a number between 0-23')
|
||||
day_begins=4
|
||||
|
||||
location=%city, %state
|
||||
year=%Y
|
||||
month=%B
|
||||
# date=%Y
|
||||
# custom=%date %album
|
||||
full_path=%year/%month/%location
|
||||
dirs_path=%u{%Y-%m}/{city}|{city}-{%Y}/{folders[:1]}/{folder}
|
||||
name={%Y-%m-%b-%H-%M-%S}-{basename}.%l{ext}
|
||||
|
||||
[Exclusions]
|
||||
name1=.directory
|
||||
name2=.DS_Store
|
||||
|
||||
[Geolocation]
|
||||
# geocoder: Nominatim or MapQuest
|
||||
|
|
24
elodie.py
24
elodie.py
|
@ -19,6 +19,7 @@ from elodie import constants
|
|||
from elodie import geolocation
|
||||
from elodie import log
|
||||
from elodie.compatability import _decode
|
||||
from elodie import config
|
||||
from elodie.config import load_config
|
||||
from elodie.filesystem import FileSystem
|
||||
from elodie.gui import CompareImageApp
|
||||
|
@ -182,8 +183,6 @@ def _import(destination, source, file, album_from_folder, trash,
|
|||
@click.option('--ignore-tags', '-i', default=set(), multiple=True,
|
||||
help='Specific tags or group that will be ignored when\
|
||||
searching for file data. Example \'File:FileModifyDate\' or \'Filename\'' )
|
||||
@click.option('--keep-folders', '-k', default=None,
|
||||
help='Folder from given level are keep back')
|
||||
@click.option('--max-deep', '-m', default=None,
|
||||
help='Maximum level to proceed. Number from 0 to desired level.')
|
||||
@click.option('--remove-duplicates', '-r', default=False, is_flag=True,
|
||||
|
@ -193,7 +192,7 @@ def _import(destination, source, file, album_from_folder, trash,
|
|||
help='True if you want to see details of file processing')
|
||||
@click.argument('paths', required=True, nargs=-1, type=click.Path())
|
||||
def _sort(debug, dry_run, destination, copy, exclude_regex, filter_by_ext, ignore_tags,
|
||||
keep_folders, max_deep, remove_duplicates, verbose, paths):
|
||||
max_deep, remove_duplicates, verbose, paths):
|
||||
"""Sort files or directories by reading their EXIF and organizing them
|
||||
according to config.ini preferences.
|
||||
"""
|
||||
|
@ -210,9 +209,6 @@ def _sort(debug, dry_run, destination, copy, exclude_regex, filter_by_ext, ignor
|
|||
else:
|
||||
constants.debug = logging.ERROR
|
||||
|
||||
if keep_folders is not None:
|
||||
keep_folders = int(keep_folders)
|
||||
|
||||
if max_deep is not None:
|
||||
max_deep = int(max_deep)
|
||||
|
||||
|
@ -232,24 +228,26 @@ def _sort(debug, dry_run, destination, copy, exclude_regex, filter_by_ext, ignor
|
|||
if not os.path.exists(destination):
|
||||
logger.error(f'Directory {destination} does not exist')
|
||||
|
||||
conf = config.load_config(constants.CONFIG_FILE)
|
||||
path_format = config.get_path_definition(conf)
|
||||
|
||||
# if no exclude list was passed in we check if there's a config
|
||||
if len(exclude_regex) == 0:
|
||||
config = load_config(constants.CONFIG_FILE)
|
||||
if 'Exclusions' in config:
|
||||
exclude_regex = [value for key, value in config.items('Exclusions')]
|
||||
if 'Exclusions' in conf:
|
||||
exclude_regex = [value for key, value in conf.items('Exclusions')]
|
||||
|
||||
exclude_regex_list = set(exclude_regex)
|
||||
|
||||
# Initialize Db
|
||||
db = Db(destination)
|
||||
|
||||
if 'Directory' in config and 'day_begins' in config['Directory']:
|
||||
config_directory = config['Directory']
|
||||
if 'Directory' in conf and 'day_begins' in conf['Directory']:
|
||||
config_directory = conf['Directory']
|
||||
day_begins = config_directory['day_begins']
|
||||
else:
|
||||
day_begins = 0
|
||||
filesystem = FileSystem(mode, dry_run, exclude_regex_list, logger,
|
||||
day_begins, filter_by_ext, keep_folders, max_deep)
|
||||
filesystem = FileSystem(day_begins, dry_run, exclude_regex_list,
|
||||
filter_by_ext, logger, max_deep, mode, path_format)
|
||||
|
||||
summary, has_errors = filesystem.sort_files(paths, destination, db,
|
||||
remove_duplicates, ignore_tags)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""Load config file as a singleton."""
|
||||
from configparser import RawConfigParser
|
||||
from os import path
|
||||
from elodie import constants
|
||||
|
||||
|
||||
def load_config(file):
|
||||
|
@ -33,3 +34,24 @@ def load_config_for_plugin(name, file):
|
|||
return config[key]
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_path_definition(config):
|
||||
"""Returns a list of folder definitions.
|
||||
|
||||
Each element in the list represents a folder.
|
||||
Fallback folders are supported and are nested lists.
|
||||
|
||||
:returns: string
|
||||
"""
|
||||
|
||||
if 'Path' in config:
|
||||
if 'format' in config['Path']:
|
||||
return config['Path']['format']
|
||||
elif 'dirs_path' and 'name' in config['Path']:
|
||||
return path.join(config['Path']['dirs_path'],
|
||||
config['Path']['name'])
|
||||
|
||||
return path.join(constants.default_path, constants.default_name)
|
||||
|
||||
|
||||
|
|
|
@ -16,6 +16,9 @@ if (
|
|||
):
|
||||
application_directory = environ['ELODIE_APPLICATION_DIRECTORY']
|
||||
|
||||
default_path = '{%Y-%m-%b}/{album}|{city}|{"Unknown Location"}'
|
||||
default_name = '{%Y-%m-%d_%H-%M-%S}-{original_name}-{title}.{ext}'
|
||||
|
||||
#: File in which to store details about media Elodie has seen.
|
||||
hash_db = 'hash.json'
|
||||
# TODO will be removed eventualy later
|
||||
|
|
|
@ -9,7 +9,9 @@ import filecmp
|
|||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
@ -29,9 +31,9 @@ from elodie.summary import Summary
|
|||
class FileSystem(object):
|
||||
"""A class for interacting with the file system."""
|
||||
|
||||
def __init__(self, mode='copy', dry_run=False, exclude_regex_list=set(),
|
||||
logger=logging.getLogger(), day_begins=0, filter_by_ext=(),
|
||||
keep_folders=None, max_deep=None):
|
||||
def __init__(self, day_begins=0, dry_run=False, exclude_regex_list=set(),
|
||||
filter_by_ext=(), logger=logging.getLogger(), max_deep=None,
|
||||
mode='copy', path_format=None):
|
||||
# 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',
|
||||
|
@ -51,6 +53,26 @@ class FileSystem(object):
|
|||
# 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.items = {
|
||||
'album': '{album}',
|
||||
'basename': '{basename}',
|
||||
'camera_make': '{camera_make}',
|
||||
'camera_model': '{camera_model}',
|
||||
'city': '{city}',
|
||||
'custom': '{".*"}',
|
||||
'country': '{country}',
|
||||
# 'folder': '{folder[<>]?[-+]?[1-9]?}',
|
||||
'folder': '{folder}',
|
||||
'folders': '{folders(\[[0-9:]{0,3}\])?}',
|
||||
'location': '{location}',
|
||||
'ext': '{ext}',
|
||||
'original_name': '{original_name}',
|
||||
'state': '{state}',
|
||||
'title': '{title}',
|
||||
'date': '{(%[a-zA-Z][^a-zA-Z]*){1,8}}' # search for date format string
|
||||
}
|
||||
|
||||
self.whitespace_regex = '[ \t\n\r\f\v]+'
|
||||
|
||||
self.dry_run = dry_run
|
||||
|
@ -60,8 +82,12 @@ class FileSystem(object):
|
|||
self.summary = Summary()
|
||||
self.day_begins = day_begins
|
||||
self.filter_by_ext = filter_by_ext
|
||||
self.keep_folders = keep_folders
|
||||
self.max_deep = max_deep
|
||||
if path_format:
|
||||
self.path_format = path_format
|
||||
else:
|
||||
self.path_format = os.path.join(constants.default_path,
|
||||
constants.default_name)
|
||||
|
||||
# Instantiate a plugins object
|
||||
self.plugins = Plugins()
|
||||
|
@ -201,7 +227,7 @@ class FileSystem(object):
|
|||
# [
|
||||
# [('date', '%Y-%m-%d_%H-%M-%S')],
|
||||
# [('original_name', '')], [('title', '')], // contains a fallback
|
||||
# [('extension', '')]
|
||||
# [('ext', '')]
|
||||
# ]
|
||||
name_template, definition = self.get_file_name_definition()
|
||||
|
||||
|
@ -232,8 +258,12 @@ class FileSystem(object):
|
|||
)
|
||||
break
|
||||
elif part in ('album', 'extension', 'title'):
|
||||
if metadata[part]:
|
||||
this_value = re.sub(self.whitespace_regex, '-', metadata[part].strip())
|
||||
key = part
|
||||
if part == 'extension':
|
||||
key = 'ext'
|
||||
if metadata[key]:
|
||||
this_value = re.sub(self.whitespace_regex, '-',
|
||||
metadata[key].strip())
|
||||
break
|
||||
elif part in ('original_name'):
|
||||
# First we check if we have metadata['original_name'].
|
||||
|
@ -297,7 +327,7 @@ class FileSystem(object):
|
|||
[
|
||||
('date', '%Y-%m-%d'),
|
||||
[
|
||||
('location', '%city'),
|
||||
('default', '%city'),
|
||||
('album', ''),
|
||||
('"Unknown Location", '')
|
||||
]
|
||||
|
@ -320,7 +350,7 @@ class FileSystem(object):
|
|||
|
||||
# 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
|
||||
# I.e. %date-%original_name-%title.%extension => ['date', 'original_name', 'title', 'ext'] #noqa
|
||||
path_parts = re.findall(
|
||||
'(\%[a-z_]+)',
|
||||
config_file['name']
|
||||
|
@ -357,7 +387,7 @@ class FileSystem(object):
|
|||
[
|
||||
('date', '%Y-%m-%d'),
|
||||
[
|
||||
('location', '%city'),
|
||||
('default', '%city'),
|
||||
('album', ''),
|
||||
('"Unknown Location", '')
|
||||
]
|
||||
|
@ -407,6 +437,7 @@ class FileSystem(object):
|
|||
|
||||
return self.cached_folder_path_definition
|
||||
|
||||
|
||||
def get_folder_path(self, metadata, db, path_parts=None):
|
||||
"""Given a media's metadata this function returns the folder path as a string.
|
||||
|
||||
|
@ -433,6 +464,167 @@ class FileSystem(object):
|
|||
break
|
||||
return os.path.join(*path)
|
||||
|
||||
|
||||
def get_location_part(self, mask, part, place_name):
|
||||
"""Takes a mask for a location and interpolates the actual place names.
|
||||
|
||||
Given these parameters here are the outputs.
|
||||
|
||||
mask = 'city'
|
||||
part = 'city-random'
|
||||
place_name = {'city': u'Sunnyvale'}
|
||||
return 'Sunnyvale'
|
||||
|
||||
mask = 'location'
|
||||
part = 'location'
|
||||
place_name = {'default': u'Sunnyvale', 'city': u'Sunnyvale'}
|
||||
return 'Sunnyvale'
|
||||
|
||||
:returns: str
|
||||
"""
|
||||
folder_name = part
|
||||
if(mask in place_name):
|
||||
replace_target = mask
|
||||
replace_with = place_name[mask]
|
||||
else:
|
||||
replace_target = part
|
||||
replace_with = ''
|
||||
|
||||
folder_name = folder_name.replace(
|
||||
replace_target,
|
||||
replace_with,
|
||||
)
|
||||
|
||||
return folder_name
|
||||
|
||||
|
||||
|
||||
def get_part(self, item, mask, metadata, db, subdirs):
|
||||
"""Parse a specific folder's name given a mask and metadata.
|
||||
|
||||
:param item: Name of the item as defined in the path (i.e. date from %date)
|
||||
:param mask: Mask representing the template for the path (i.e. %city %state
|
||||
:param metadata: Metadata dictionary.
|
||||
:returns: str
|
||||
"""
|
||||
|
||||
# Each item has its own custom logic and we evaluate a single item and return
|
||||
# the evaluated string.
|
||||
if item in ('basename'):
|
||||
return os.path.basename(metadata['base_name'])
|
||||
elif item is 'date':
|
||||
date = self.get_date_taken(metadata)
|
||||
# early morning photos can be grouped with previous day
|
||||
date = self.check_for_early_morning_photos(date)
|
||||
if date is not None:
|
||||
return date.strftime(mask)
|
||||
else:
|
||||
return ''
|
||||
|
||||
elif item in ('location', 'city', 'state', 'country'):
|
||||
place_name = geolocation.place_name(
|
||||
metadata['latitude'],
|
||||
metadata['longitude'],
|
||||
db
|
||||
)
|
||||
if item == 'location':
|
||||
mask = 'default'
|
||||
|
||||
return self.get_location_part(mask, item, place_name)
|
||||
elif item in ('folder'):
|
||||
return os.path.basename(subdirs)
|
||||
|
||||
elif item in ('folders'):
|
||||
folders = pathlib.Path(subdirs).parts
|
||||
folders = eval(mask)
|
||||
|
||||
return os.path.join(*folders)
|
||||
|
||||
elif item in ('album','camera_make', 'camera_model', 'ext',
|
||||
'title'):
|
||||
if metadata[item]:
|
||||
# return metadata[item]
|
||||
return re.sub(self.whitespace_regex, '_', metadata[item].strip())
|
||||
elif item 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[item]:
|
||||
part = 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.
|
||||
part = re.sub(
|
||||
'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-',
|
||||
'',
|
||||
metadata['base_name']
|
||||
)
|
||||
if(len(part) == 0):
|
||||
part = metadata['base_name']
|
||||
# Lastly we want to sanitize the name
|
||||
return re.sub(self.whitespace_regex, '_', part.strip())
|
||||
elif item in 'custom':
|
||||
# Fallback string
|
||||
return mask[1:-1]
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def get_path(self, metadata, db, subdirs=''):
|
||||
"""path_format: {%Y-%d-%m}/%u{city}/{album}
|
||||
|
||||
Returns file path.
|
||||
|
||||
:returns: string"""
|
||||
|
||||
# if self.path_format is None:
|
||||
# path_format = self.get_path_definition()
|
||||
path_format = self.path_format
|
||||
# self.cached_folder_path_definition = []
|
||||
path = []
|
||||
path_parts = path_format.split('/')
|
||||
for path_part in path_parts:
|
||||
this_parts = path_part.split('|')
|
||||
# p = []
|
||||
for this_part in this_parts:
|
||||
# parts = ''
|
||||
for item, mask in self.items.items():
|
||||
matched = re.search(mask, this_part)
|
||||
if matched:
|
||||
# parts = re.split(mask, this_part)
|
||||
# parts = this_part.split('%')[1:]
|
||||
part = self.get_part(item, matched.group()[1:-1], metadata, db,
|
||||
subdirs)
|
||||
|
||||
# Capitalization
|
||||
umask = '%u' + mask
|
||||
lmask = '%l' + mask
|
||||
if re.search(umask, this_part):
|
||||
this_part = re.sub(umask, part.upper(), this_part)
|
||||
elif re.search(lmask, this_part):
|
||||
this_part = re.sub(lmask, part.lower(), this_part)
|
||||
else:
|
||||
this_part = re.sub(mask, part, this_part)
|
||||
|
||||
if this_part:
|
||||
# Check if all masks are substituted
|
||||
if True in [c in this_part for c in '{}']:
|
||||
self.logger.error(f'Format path part invalid: \
|
||||
{this_part}')
|
||||
sys.exit(1)
|
||||
|
||||
path.append(this_part.strip())
|
||||
# We break as soon as we have a value to append
|
||||
break
|
||||
# Else we continue for fallbacks
|
||||
|
||||
return os.path.join(*path)
|
||||
|
||||
|
||||
def get_date_from_string(self, string, user_regex=None):
|
||||
# If missing datetime from EXIF data check if filename is in datetime format.
|
||||
# For this use a user provided regex if possible.
|
||||
|
@ -811,10 +1003,7 @@ class FileSystem(object):
|
|||
self.max_deep):
|
||||
if dirname == os.path.join(path, '.elodie'):
|
||||
continue
|
||||
if self.keep_folders is not None:
|
||||
if level < self.keep_folders:
|
||||
subdirs = ''
|
||||
else:
|
||||
|
||||
subdirs = os.path.join(subdirs, os.path.basename(dirname))
|
||||
|
||||
for filename in filenames:
|
||||
|
@ -850,16 +1039,14 @@ class FileSystem(object):
|
|||
if media:
|
||||
metadata = media.get_metadata()
|
||||
# Get the destination path according to metadata
|
||||
directory_name = self.get_folder_path(metadata, db)
|
||||
file_name = self.get_file_name(metadata)
|
||||
file_path = self.get_path(metadata, db, subdirs=subdirs)
|
||||
else:
|
||||
# Keep same directory structure
|
||||
directory_name = os.path.dirname(os.path.relpath(src_path,
|
||||
path))
|
||||
file_name = os.path.basename(src_path)
|
||||
file_path = os.path.relpath(src_path, path)
|
||||
|
||||
dest_directory = os.path.join(destination, directory_name)
|
||||
dest_path = os.path.join(dest_directory, subdirs, file_name)
|
||||
dest_directory = os.path.join(destination,
|
||||
os.path.dirname(file_path))
|
||||
dest_path = os.path.join(destination, file_path)
|
||||
self.create_directory(dest_directory)
|
||||
result = self.sort_file(src_path, dest_path, remove_duplicates)
|
||||
if result:
|
||||
|
|
|
@ -111,7 +111,7 @@ class Media():
|
|||
return None
|
||||
|
||||
source = self.source
|
||||
return os.path.splitext(source)[1][1:].lower()
|
||||
return os.path.splitext(source)[1][1:]
|
||||
|
||||
|
||||
def get_metadata(self, update_cache=False, album_from_folder=False):
|
||||
|
@ -146,7 +146,7 @@ class Media():
|
|||
'mime_type': self.get_mimetype(),
|
||||
'original_name': self.get_original_name(),
|
||||
'base_name': os.path.basename(os.path.splitext(source)[0]),
|
||||
'extension': self.get_extension(),
|
||||
'ext': self.get_extension(),
|
||||
'directory_path': os.path.dirname(source)
|
||||
}
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@ class GooglePhotos(PluginBase):
|
|||
self.session = None
|
||||
|
||||
def after(self, file_path, destination_folder, final_file_path, metadata):
|
||||
extension = metadata['extension']
|
||||
extension = metadata['ext']
|
||||
if(extension in Photo.extensions or extension in Video.extensions):
|
||||
self.log(u'Added {} to db.'.format(final_file_path))
|
||||
self.db.set(final_file_path, metadata['original_name'])
|
||||
|
|
|
@ -778,7 +778,8 @@ def test_sort_files():
|
|||
temporary_folder_destination, folder_destination = helper.create_working_folder()
|
||||
|
||||
db = Db(folder)
|
||||
filesystem = FileSystem()
|
||||
path_format = os.path.join(constants.default_path, constants.default_name)
|
||||
filesystem = FileSystem(path_format=path_format)
|
||||
|
||||
filenames = ['photo.png', 'plain.jpg', 'text.txt', 'withoutextension']
|
||||
for src_file in filenames:
|
||||
|
|
Loading…
Reference in New Issue