368 lines
12 KiB
Python
Executable File
368 lines
12 KiB
Python
Executable File
#!/usr/bin/env python
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
import click
|
|
|
|
from ordigi.config import Config
|
|
from ordigi import constants
|
|
from ordigi import log
|
|
from ordigi.collection import Collection
|
|
from ordigi.geolocation import GeoLocation
|
|
from ordigi.media import Media, get_all_subclasses
|
|
from ordigi.summary import Summary
|
|
|
|
|
|
_logger_options = [
|
|
click.option('--debug', default=False, is_flag=True,
|
|
help='Override the value in constants.py with True.'),
|
|
click.option('--verbose', '-v', default=False, is_flag=True,
|
|
help='True if you want to see details of file processing')
|
|
]
|
|
|
|
_dry_run_options = [
|
|
click.option('--dry-run', default=False, is_flag=True,
|
|
help='Dry run only, no change made to the filesystem.')
|
|
]
|
|
|
|
_filter_option = [
|
|
click.option('--exclude', '-e', default=set(), multiple=True,
|
|
help='Directories or files to exclude.'),
|
|
click.option('--filter-by-ext', '-f', default=set(), multiple=True,
|
|
help="""Use filename
|
|
extension to filter files for sorting. If value is '*', use
|
|
common media file extension for filtering. Ignored files remain in
|
|
the same directory structure""" ),
|
|
click.option('--glob', '-g', default='**/*',
|
|
help='Glob file selection')
|
|
]
|
|
|
|
|
|
def print_help(command):
|
|
click.echo(command.get_help(click.Context(sort)))
|
|
|
|
|
|
def add_options(options):
|
|
def _add_options(func):
|
|
for option in reversed(options):
|
|
func = option(func)
|
|
return func
|
|
return _add_options
|
|
|
|
|
|
def _get_exclude(opt, exclude):
|
|
# if no exclude list was passed in we check if there's a config
|
|
if len(exclude) == 0:
|
|
exclude = opt['exclude']
|
|
return set(exclude)
|
|
|
|
|
|
@click.command('sort')
|
|
@add_options(_logger_options)
|
|
@add_options(_dry_run_options)
|
|
@add_options(_filter_option)
|
|
@click.option('--album-from-folder', default=False, is_flag=True,
|
|
help="Use images' folders as their album names.")
|
|
@click.option('--destination', '-d', type=click.Path(file_okay=False),
|
|
default=None, help='Sort files into this directory.')
|
|
@click.option('--clean', '-C', default=False, is_flag=True,
|
|
help='Clean empty folders')
|
|
@click.option('--copy', '-c', default=False, is_flag=True,
|
|
help='True if you want files to be copied over from src_dir to\
|
|
dest_dir rather than moved')
|
|
@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('--interactive', '-i', default=False, is_flag=True,
|
|
help="Interactive mode")
|
|
@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,
|
|
help='True to remove files that are exactly the same in name\
|
|
and a file hash')
|
|
@click.option('--reset-cache', '-r', default=False, is_flag=True,
|
|
help='Regenerate the hash.json and location.json database ')
|
|
@click.option('--use-date-filename', '-f', default=False, is_flag=True,
|
|
help="Use filename date for media original date.")
|
|
@click.option('--use-file-dates', '-F', default=False, is_flag=True,
|
|
help="Use file date created or modified for media original date.")
|
|
@click.argument('paths', required=True, nargs=-1, type=click.Path())
|
|
def sort(**kwargs):
|
|
"""Sort files or directories by reading their EXIF and organizing them
|
|
according to ordigi.conf preferences.
|
|
"""
|
|
|
|
debug = kwargs['debug']
|
|
destination = kwargs['destination']
|
|
verbose = kwargs['verbose']
|
|
|
|
paths = kwargs['paths']
|
|
|
|
if kwargs['copy']:
|
|
mode = 'copy'
|
|
else:
|
|
mode = 'move'
|
|
|
|
logger = log.get_logger(verbose, debug)
|
|
|
|
max_deep = kwargs['max_deep']
|
|
if max_deep is not None:
|
|
max_deep = int(max_deep)
|
|
|
|
cache = True
|
|
if kwargs['reset_cache']:
|
|
cache = False
|
|
|
|
if len(paths) > 1:
|
|
if not destination:
|
|
# Use last path argument as destination
|
|
destination = paths[-1]
|
|
paths = paths[0:-1]
|
|
elif paths:
|
|
# Source and destination are the same
|
|
destination = paths[0]
|
|
else:
|
|
logger.error(f'`ordigi sort` need at least one path argument')
|
|
sys.exit(1)
|
|
|
|
paths = set(paths)
|
|
|
|
config = Config(constants.CONFIG_FILE)
|
|
opt = config.get_options()
|
|
|
|
exclude = _get_exclude(opt, kwargs['exclude'])
|
|
filter_by_ext = set(kwargs['filter_by_ext'])
|
|
|
|
collection = Collection(destination, opt['path_format'],
|
|
kwargs['album_from_folder'], cache, opt['day_begins'], kwargs['dry_run'],
|
|
exclude, filter_by_ext, kwargs['glob'], kwargs['interactive'],
|
|
logger, max_deep, mode, kwargs['use_date_filename'],
|
|
kwargs['use_file_dates'])
|
|
|
|
loc = GeoLocation(opt['geocoder'], opt['prefer_english_names'],
|
|
opt['timeout'])
|
|
|
|
summary, result = collection.sort_files(paths, loc,
|
|
kwargs['remove_duplicates'], kwargs['ignore_tags'])
|
|
|
|
if kwargs['clean']:
|
|
remove_empty_folders(destination, logger)
|
|
|
|
if verbose or debug:
|
|
summary.print()
|
|
|
|
if not result:
|
|
sys.exit(1)
|
|
|
|
|
|
def remove_empty_folders(path, logger, remove_root=True):
|
|
'Function to remove empty folders'
|
|
if not os.path.isdir(path):
|
|
return
|
|
|
|
# remove empty subfolders
|
|
files = os.listdir(path)
|
|
if len(files):
|
|
for f in files:
|
|
fullpath = os.path.join(path, f)
|
|
if os.path.isdir(fullpath):
|
|
remove_empty_folders(fullpath, logger)
|
|
|
|
# if folder empty, delete it
|
|
files = os.listdir(path)
|
|
if len(files) == 0 and remove_root:
|
|
logger.info(f"Removing empty folder: {path}")
|
|
os.rmdir(path)
|
|
|
|
|
|
@click.command('clean')
|
|
@add_options(_logger_options)
|
|
@add_options(_dry_run_options)
|
|
@add_options(_filter_option)
|
|
@click.option('--dedup-regex', '-d', default=set(), multiple=True,
|
|
help='Regex to match duplicate strings parts')
|
|
@click.option('--folders', '-f', default=False, is_flag=True,
|
|
help='Remove empty folders')
|
|
@click.option('--max-deep', '-m', default=None,
|
|
help='Maximum level to proceed. Number from 0 to desired level.')
|
|
@click.option('--path-string', '-p', default=False, is_flag=True,
|
|
help='Deduplicate path string')
|
|
@click.option('--remove-duplicates', '-R', default=False, is_flag=True,
|
|
help='True to remove files that are exactly the same in name\
|
|
and a file hash')
|
|
@click.option('--root', '-r', type=click.Path(file_okay=False),
|
|
default=None, help='Root dir of media collection. If not set, use path')
|
|
@click.argument('path', required=True, nargs=1, type=click.Path())
|
|
def clean(**kwargs):
|
|
"""Remove empty folders
|
|
Usage: clean [--verbose|--debug] directory [removeRoot]"""
|
|
|
|
debug = kwargs['debug']
|
|
dry_run = kwargs['dry_run']
|
|
folders = kwargs['folders']
|
|
root = kwargs['root']
|
|
verbose = kwargs['verbose']
|
|
|
|
path = kwargs['path']
|
|
|
|
logger = log.get_logger(verbose, debug)
|
|
clean_all = False
|
|
if not folders:
|
|
clean_all = True
|
|
if not root:
|
|
root = path
|
|
|
|
config = Config(constants.CONFIG_FILE)
|
|
opt = config.get_options()
|
|
|
|
exclude = _get_exclude(opt, kwargs['exclude'])
|
|
filter_by_ext = set(kwargs['filter_by_ext'])
|
|
|
|
if kwargs['path_string']:
|
|
collection = Collection(root, opt['path_format'], dry_run=dry_run,
|
|
exclude=exclude, filter_by_ext=filter_by_ext, glob=kwargs['glob'],
|
|
logger=logger, max_deep=kwargs['max_deep'], mode='move')
|
|
dedup_regex = list(kwargs['dedup_regex'])
|
|
summary, result = collection.dedup_regex(path, dedup_regex, kwargs['remove_duplicates'])
|
|
|
|
if clean_all or folders:
|
|
remove_empty_folders(path, logger)
|
|
|
|
if verbose or debug:
|
|
summary.print()
|
|
|
|
if not result:
|
|
sys.exit(1)
|
|
|
|
|
|
@click.command('init')
|
|
@add_options(_logger_options)
|
|
@click.argument('path', required=True, nargs=1, type=click.Path())
|
|
def init(**kwargs):
|
|
"""Regenerate the hash.json database which contains all of the sha256 signatures of media files.
|
|
"""
|
|
config = Config(constants.CONFIG_FILE)
|
|
opt = config.get_options()
|
|
loc = GeoLocation(opt['geocoder'], opt['prefer_english_names'],
|
|
opt['timeout'])
|
|
debug = kwargs['debug']
|
|
verbose = kwargs['verbose']
|
|
logger = log.get_logger(debug, verbose)
|
|
collection = Collection(kwargs['path'], None, mode='move', logger=logger)
|
|
summary = collection.init(loc)
|
|
if verbose or debug:
|
|
summary.print()
|
|
|
|
|
|
@click.command('update')
|
|
@add_options(_logger_options)
|
|
@click.argument('path', required=True, nargs=1, type=click.Path())
|
|
def update(**kwargs):
|
|
"""Regenerate the hash.json database which contains all of the sha256 signatures of media files.
|
|
"""
|
|
config = Config(constants.CONFIG_FILE)
|
|
opt = config.get_options()
|
|
loc = GeoLocation(opt['geocoder'], opt['prefer_english_names'],
|
|
opt['timeout'])
|
|
debug = kwargs['debug']
|
|
verbose = kwargs['verbose']
|
|
logger = log.get_logger(debug, verbose)
|
|
collection = Collection(kwargs['path'], None, mode='move', logger=logger)
|
|
summary = collection.update(loc)
|
|
if verbose or debug:
|
|
summary.print()
|
|
|
|
|
|
@click.command('check')
|
|
@add_options(_logger_options)
|
|
@click.argument('path', required=True, nargs=1, type=click.Path())
|
|
def check(**kwargs):
|
|
"""check db and verify hashes"""
|
|
debug = kwargs['debug']
|
|
verbose = kwargs['verbose']
|
|
logger = log.get_logger(debug, verbose)
|
|
collection = Collection(kwargs['path'], None, mode='move', logger=logger)
|
|
result = collection.check_db()
|
|
if result:
|
|
summary, result = collection.check_files()
|
|
if verbose or debug:
|
|
summary.print()
|
|
if not result:
|
|
sys.exit(1)
|
|
else:
|
|
self.logger.error('Db data is not accurate run `ordigi init`')
|
|
sys.exit(1)
|
|
|
|
|
|
@click.command('compare')
|
|
@add_options(_logger_options)
|
|
@add_options(_dry_run_options)
|
|
@add_options(_filter_option)
|
|
@click.option('--find-duplicates', '-f', default=False, is_flag=True)
|
|
@click.option('--output-dir', '-o', default=False, is_flag=True, help='output\
|
|
dir')
|
|
@click.option('--remove-duplicates', '-r', default=False, is_flag=True)
|
|
@click.option('--revert-compare', '-R', default=False, is_flag=True, help='Revert\
|
|
compare')
|
|
@click.option('--root', '-r', type=click.Path(file_okay=False),
|
|
default=None, help='Root dir of media collection. If not set, use path')
|
|
@click.option('--similar-to', '-s', default=False, help='Similar to given\
|
|
image')
|
|
@click.option('--similarity', '-S', default=80, help='Similarity level for\
|
|
images')
|
|
@click.argument('path', nargs=1, required=True)
|
|
def compare(**kwargs):
|
|
'''Compare files in directories'''
|
|
|
|
debug = kwargs['debug']
|
|
dry_run = kwargs['dry_run']
|
|
root = kwargs['root']
|
|
verbose = kwargs['verbose']
|
|
|
|
path = kwargs['path']
|
|
|
|
logger = log.get_logger(verbose, debug)
|
|
if not root:
|
|
root = kwargs['path']
|
|
|
|
config = Config(constants.CONFIG_FILE)
|
|
opt = config.get_options()
|
|
|
|
exclude = _get_exclude(opt, kwargs['exclude'])
|
|
filter_by_ext = set(kwargs['filter_by_ext'])
|
|
|
|
collection = Collection(root, None, exclude=exclude,
|
|
filter_by_ext=filter_by_ext, glob=kwargs['glob'],
|
|
mode='move', dry_run=dry_run, logger=logger)
|
|
|
|
if kwargs['revert_compare']:
|
|
summary, result = collection.revert_compare(path)
|
|
else:
|
|
summary, result = collection.sort_similar_images(path, kwargs['similarity'])
|
|
|
|
if verbose or debug:
|
|
summary.print()
|
|
|
|
if not result:
|
|
sys.exit(1)
|
|
|
|
|
|
@click.group()
|
|
def main(**kwargs):
|
|
pass
|
|
|
|
|
|
main.add_command(clean)
|
|
main.add_command(check)
|
|
main.add_command(compare)
|
|
main.add_command(init)
|
|
main.add_command(sort)
|
|
main.add_command(update)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|