Compare commits

..

17 Commits

Author SHA1 Message Date
Cédric Leporcq d54ccd64f6 Update LICENCE file and .gitignore 2022-08-28 17:11:03 +02:00
Cédric Leporcq 836792429f Change command line options for log levels 2022-08-28 14:22:30 +02:00
Cédric Leporcq b7435c4eac Modify input text fonction and add Input Class 2022-08-28 14:19:58 +02:00
Cédric Leporcq 723f549f73 Modify db checking for best performances 2022-08-28 14:19:58 +02:00
Cédric Leporcq 47b9aa57ae Add checksum dict 2022-08-28 14:19:58 +02:00
Cédric Leporcq 9e32052ce3 Verify checksum in check_db 2022-08-28 14:19:58 +02:00
Cédric Leporcq ed58383ea0 Add --ckecksum option to update command 2022-08-28 14:19:58 +02:00
Cédric Leporcq f6816c6c01 Allow import or sort single files 2022-08-28 07:50:43 +02:00
Cédric Leporcq b7f0cafe98 Update .gitignore 2022-08-28 07:50:43 +02:00
Cédric Leporcq 573a63998e Revamp and fix options 2022-08-28 07:50:43 +02:00
Cédric Leporcq 01b47c8c40 Fix date detection in filenames 2022-08-28 07:50:43 +02:00
Cédric Leporcq 52768f64db Add 60s tolerance when compare date_original, date_filename and
date_created
2022-08-28 07:50:43 +02:00
Cédric Leporcq cdfa408206 Fix get_date_from_string function 2022-08-28 07:50:43 +02:00
Cédric Leporcq eee3c71f6a Remove unused import 2022-08-28 07:50:43 +02:00
Cédric Leporcq 1eb2a2c6e0 Fix set original name to EXIF metadata 2022-08-28 07:50:43 +02:00
Cédric Leporcq 58e282fd87 Fix database 2022-08-28 07:50:43 +02:00
Cédric Leporcq a1ba0663b6 Fix edit metadata 2022-08-28 07:50:43 +02:00
13 changed files with 664 additions and 1006 deletions

143
.gitignore vendored
View File

@ -1,20 +1,139 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# Compiled python modules. # Compiled python modules.
*.pyc *.pyc
/build/ # Other
/.coverage
/diagnostics.lua /diagnostics.lua
docs/_build
docs/Ordigi_data_scheme.odg docs/Ordigi_data_scheme.odg
# Setuptools distribution folder.
/dist/
# Python egg metadata, regenerated from source files by setuptools.
/*.egg-info
/env/
/htmlcov
/ressources /ressources
/Session.vim /Session.vim
/tags /tags

713
LICENSE

File diff suppressed because it is too large Load Diff

View File

@ -1,119 +0,0 @@
<map version="freeplane 1.7.0">
<!--To view this file, download free mind mapping software Freeplane from http://freeplane.sourceforge.net -->
<node TEXT="elodie" FOLDED="false" ID="ID_577640973" CREATED="1624709002278" MODIFIED="1624709019473" STYLE="oval"><hook NAME="MapStyle">
<conditional_styles>
<conditional_style ACTIVE="true" LOCALIZED_STYLE_REF="styles.connection" LAST="false">
<node_periodic_level_condition PERIOD="2" REMAINDER="1"/>
</conditional_style>
<conditional_style ACTIVE="true" LOCALIZED_STYLE_REF="styles.topic" LAST="false">
<node_level_condition VALUE="2" MATCH_CASE="false" MATCH_APPROXIMATELY="false" COMPARATION_RESULT="0" SUCCEED="true"/>
</conditional_style>
<conditional_style ACTIVE="true" LOCALIZED_STYLE_REF="styles.subtopic" LAST="false">
<node_level_condition VALUE="4" MATCH_CASE="false" MATCH_APPROXIMATELY="false" COMPARATION_RESULT="0" SUCCEED="true"/>
</conditional_style>
<conditional_style ACTIVE="true" LOCALIZED_STYLE_REF="styles.subsubtopic" LAST="false">
<node_level_condition VALUE="6" MATCH_CASE="false" MATCH_APPROXIMATELY="false" COMPARATION_RESULT="0" SUCCEED="true"/>
</conditional_style>
</conditional_styles>
<properties edgeColorConfiguration="#808080ff,#ff0000ff,#0000ffff,#00ff00ff,#ff00ffff,#00ffffff,#7c0000ff,#00007cff,#007c00ff,#7c007cff,#007c7cff,#7c7c00ff" fit_to_viewport="false" show_note_icons="true"/>
<map_styles>
<stylenode LOCALIZED_TEXT="styles.root_node" STYLE="oval" UNIFORM_SHAPE="true" VGAP_QUANTITY="24.0 pt">
<font SIZE="24"/>
<stylenode LOCALIZED_TEXT="styles.predefined" POSITION="right" STYLE="bubble">
<stylenode LOCALIZED_TEXT="default" ICON_SIZE="12.0 pt" COLOR="#000000" STYLE="fork">
<font NAME="Arial" SIZE="10" BOLD="false" ITALIC="false"/>
</stylenode>
<stylenode LOCALIZED_TEXT="defaultstyle.details"/>
<stylenode LOCALIZED_TEXT="defaultstyle.attributes">
<font SIZE="9"/>
</stylenode>
<stylenode LOCALIZED_TEXT="defaultstyle.note" COLOR="#000000" BACKGROUND_COLOR="#ffffff" TEXT_ALIGN="LEFT"/>
<stylenode LOCALIZED_TEXT="defaultstyle.floating">
<edge STYLE="hide_edge"/>
<cloud COLOR="#f0f0f0" SHAPE="ROUND_RECT"/>
</stylenode>
</stylenode>
<stylenode LOCALIZED_TEXT="styles.user-defined" POSITION="right" STYLE="bubble">
<stylenode LOCALIZED_TEXT="styles.topic" COLOR="#18898b" STYLE="fork">
<font NAME="Liberation Sans" SIZE="10" BOLD="true"/>
</stylenode>
<stylenode LOCALIZED_TEXT="styles.subtopic" COLOR="#cc3300" STYLE="fork">
<font NAME="Liberation Sans" SIZE="10" BOLD="true"/>
</stylenode>
<stylenode LOCALIZED_TEXT="styles.subsubtopic" COLOR="#669900">
<font NAME="Liberation Sans" SIZE="10" BOLD="true"/>
</stylenode>
<stylenode LOCALIZED_TEXT="styles.connection" COLOR="#606060" STYLE="fork">
<font NAME="Arial" SIZE="8" BOLD="false"/>
</stylenode>
</stylenode>
<stylenode LOCALIZED_TEXT="styles.AutomaticLayout" POSITION="right" STYLE="bubble">
<stylenode LOCALIZED_TEXT="AutomaticLayout.level.root" COLOR="#000000" STYLE="oval">
<font SIZE="18"/>
</stylenode>
<stylenode LOCALIZED_TEXT="AutomaticLayout.level,1" COLOR="#0033ff">
<font SIZE="16"/>
</stylenode>
<stylenode LOCALIZED_TEXT="AutomaticLayout.level,2" COLOR="#00b439">
<font SIZE="14"/>
</stylenode>
<stylenode LOCALIZED_TEXT="AutomaticLayout.level,3" COLOR="#990000">
<font SIZE="12"/>
</stylenode>
<stylenode LOCALIZED_TEXT="AutomaticLayout.level,4" COLOR="#111111">
<font SIZE="10"/>
</stylenode>
</stylenode>
</stylenode>
</map_styles>
</hook>
<node TEXT="import" POSITION="right" ID="ID_1958811617" CREATED="1624709031603" MODIFIED="1624710428698"><richcontent TYPE="NOTE">
<html>
<head>
</head>
<body>
<p>
Import from external source
</p>
</body>
</html>
</richcontent>
<node TEXT="--update" ID="ID_1408411362" CREATED="1624710635676" MODIFIED="1624710643751"/>
</node>
<node TEXT="update" POSITION="right" ID="ID_200299843" CREATED="1624709041259" MODIFIED="1624710451112"><richcontent TYPE="NOTE">
<html>
<head>
</head>
<body>
<p>
Update metadata
</p>
</body>
</html>
</richcontent>
</node>
<node TEXT="sort" FOLDED="true" POSITION="right" ID="ID_474160274" CREATED="1624709213958" MODIFIED="1624710465196"><richcontent TYPE="NOTE">
<html>
<head>
</head>
<body>
<p>
Sort photo
</p>
</body>
</html>
</richcontent>
<node TEXT="sort files" ID="ID_1215066925" CREATED="1624709364728" MODIFIED="1624709367203"/>
</node>
</node>
</map>

241
notes.md
View File

@ -1,241 +0,0 @@
# Name ideas
dozo
fog
mtool
ordigi
# Geocoders
- Pelias
- Photon
- Nominatium
# TEST
def get_exif(filename):
image = Image.open(filename)
image.verify()
return image._getexif()
def get_geotagging(exif):
if not exif:
raise ValueError("No EXIF metadata found")
geotagging = {}
for (idx, tag) in TAGS.items():
if tag == 'GPSInfo':
if idx not in exif:
raise ValueError("No EXIF geotagging found")
for (key, val) in GPSTAGS.items():
if key in exif[idx]:
geotagging[val] = exif[idx][key]
return geotagging
get_geotagging(exif)
from PIL.ExifTags import TAGS
def get_labeled_exif(exif):
labeled = {}
for (key, val) in exif.items():
labeled[TAGS.get(key)] = val
return labeled
get_geotagging(exif)
from PIL.ExifTags import GPSTAGS
get_geotagging(exif)
geotags = get_geotagging(exif)
get_location(geotags)
def get_decimal_from_dms(dms, ref):
degrees = dms[0][0] / dms[0][1]
minutes = dms[1][0] / dms[1][1] / 60.0
seconds = dms[2][0] / dms[2][1] / 3600.0
if ref in ['S', 'W']:
degrees = -degrees
minutes = -minutes
seconds = -seconds
return round(degrees + minutes + seconds, 5)
def get_coordinates(geotags):
lat = get_decimal_from_dms(geotags['GPSLatitude'], geotags['GPSLatitudeRef'])
lon = get_decimal_from_dms(geotags['GPSLongitude'], geotags['GPSLongitudeRef'])
return (lat,lon)
def get_geotagging(exif):
if not exif:
raise ValueError("No EXIF metadata found")
geotagging = {}
for (idx, tag) in TAGS.items():
if tag == 'GPSInfo':
if idx not in exif:
raise ValueError("No EXIF geotagging found")
for (key, val) in GPSTAGS.items():
if key in exif[idx]:
geotagging[val] = exif[idx][key]
return geotagging
def get_decimal_from_dms(dms, ref):
degrees = dms[0]
minutes = dms[1] / 60.0
seconds = dms[2] / 3600.0
if ref in ['S', 'W']:
degrees = -degrees
minutes = -minutes
seconds = -seconds
return round(degrees + minutes + seconds, 5)
headers = {}
params = {
'apiKey': os.environ['API_KEY'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
78/41: headers = {}
78/42:
params = {
'apiKey': os.environ['API_KEY'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
78/43:
params = {
'apiKey': os.environ['API_KEY'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
78/44: API_KEY=m5aGo8xGe4LLhxeKZYpHr2MPXGN2aDhe
78/45: API_KEY='m5aGo8xGe4LLhxeKZYpHr2MPXGN2aDhe'
78/46:
params = {
'apiKey': os.environ['API_KEY'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
78/47: API_KEY='m5aGo8xGe4LLhxeKZYpHr2MPXGN2aDhe'
78/48:
params = {
'apiKey': os.environ['API_KEY'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
78/49:
params = {
'apiKey': os.environ['m5aGo8xGe4LLhxeKZYpHr2MPXGN2aDhe'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
78/50: %load_ext autotime
78/51:
import pandas as pd
import geopandas as gpd
import geopy
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiterimport matplotlib.pyplot as plt
import plotly_express as pximport tqdm
from tqdm._tqdm_notebook import tqdm_notebook
78/52:
import pandas as pd
import geopandas as gpd
import geopy
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiterimport matplotlib.pyplot as plt
import plotly_express as px
import pandas as pd
import geopandas as gpd
from PIL import Image
filename='2021-02-24_09-33-29-20210305_081001_01.mp4'
def get_exif(filename):
image = Image.open(filename)
image.verify()
return image._getexif()
exif=get_exif(filename)
from PIL.ExifTags import TAGS
from PIL.ExifTags import GPSTAGS
def get_geotagging(exif):
if not exif:
raise ValueError("No EXIF metadata found")
geotagging = {}
for (idx, tag) in TAGS.items():
if tag == 'GPSInfo':
if idx not in exif:
raise ValueError("No EXIF geotagging found")
for (key, val) in GPSTAGS.items():
if key in exif[idx]:
geotagging[val] = exif[idx][key]
return geotagging
geotags = get_geotagging(exif)
import os
import requests
def get_location(geotags):
coords = get_coordinates(geotags)
uri = 'https://revgeocode.search.hereapi.com/v1/revgeocode'
headers = {}
params = {
'apiKey': os.environ['API_KEY'],
'at': "%s,%s" % coords,
'lang': 'en-US',
'limit': 1,
}
response = requests.get(uri, headers=headers, params=params)
try:
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
print(str(e))
return {}
def get_coordinates(geotags):
lat = get_decimal_from_dms(geotags['GPSLatitude'], geotags['GPSLatitudeRef'])
lon = get_decimal_from_dms(geotags['GPSLongitude'], geotags['GPSLongitudeRef'])
return (lat,lon)
coords = get_coordinates(geotags)
import geopy
from geopy.geocoders import Nominatim
locator = Nominatim(user_agent='myGeocoder')
# coordinates ='53.480837, -2.244914'
lat='45.58339'
lon='4.79823'
coords = lat + ',' + lon
locator.reverse(coords)
location =locator.reverse(coords)
location.address.split(',')
city=location.address.split(',')[1].strip()
country=location.address.split(',')[7].strip()
location.raw
rint
country=location.raw['address']['country']
city=location.raw['address']['village']

View File

@ -12,11 +12,26 @@ from ordigi.geolocation import GeoLocation
from ordigi import utils from ordigi import utils
_logger_options = [ _logger_options = [
click.option(
'--quiet',
'-q',
default=False,
is_flag=True,
help='Log level set to ERROR',
),
click.option( click.option(
'--verbose', '--verbose',
'-v', '-v',
default='WARNING', default=False,
help='Log level [WARNING,INFO,DEBUG,NOTSET]', is_flag=True,
help='Log level set to INFO',
),
click.option(
'--debug',
'-d',
default=False,
is_flag=True,
help='Log level set to DEBUG',
), ),
] ]
@ -168,7 +183,7 @@ def _check(**kwargs):
""" """
root = Path(kwargs['path']).expanduser().absolute() root = Path(kwargs['path']).expanduser().absolute()
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
collection = Collection(root) collection = Collection(root)
@ -191,7 +206,7 @@ def _check(**kwargs):
@add_options(_filter_options) @add_options(_filter_options)
@click.option( @click.option(
'--dedup-regex', '--dedup-regex',
'-d', '-D',
default=None, default=None,
multiple=True, multiple=True,
help='Regex to match duplicate strings parts', help='Regex to match duplicate strings parts',
@ -218,7 +233,7 @@ def _clean(**kwargs):
"""Clean media collection""" """Clean media collection"""
folders = kwargs['folders'] folders = kwargs['folders']
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
subdirs = kwargs['subdirs'] subdirs = kwargs['subdirs']
@ -268,7 +283,7 @@ def _clean(**kwargs):
def _clone(**kwargs): def _clone(**kwargs):
"""Clone media collection to another location""" """Clone media collection to another location"""
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
src_path = Path(kwargs['src']).expanduser().absolute() src_path = Path(kwargs['src']).expanduser().absolute()
@ -321,7 +336,7 @@ def _compare(**kwargs):
subdirs = kwargs['subdirs'] subdirs = kwargs['subdirs']
root = kwargs['collection'] root = kwargs['collection']
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
paths, root = _get_paths(subdirs, root) paths, root = _get_paths(subdirs, root)
@ -358,16 +373,25 @@ def _compare(**kwargs):
multiple=True, multiple=True,
help="Select exif tags groups to edit", help="Select exif tags groups to edit",
) )
@click.option(
'--overwrite',
'-O',
default=False,
is_flag=True,
help="Overwrite db and exif value by key value",
)
@click.argument('subdirs', required=False, nargs=-1, type=click.Path()) @click.argument('subdirs', required=False, nargs=-1, type=click.Path())
@click.argument('path', required=True, nargs=1, type=click.Path()) @click.argument('path', required=True, nargs=1, type=click.Path())
def _edit(**kwargs): def _edit(**kwargs):
"""Edit EXIF metadata in files or directories""" """Edit EXIF metadata in files or directories"""
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
paths, root = _get_paths(kwargs['subdirs'], kwargs['path']) paths, root = _get_paths(kwargs['subdirs'], kwargs['path'])
overwrite = kwargs['overwrite']
collection = Collection( collection = Collection(
root, root,
{ {
@ -384,13 +408,11 @@ def _edit(**kwargs):
'camera_make', 'camera_make',
'camera_model', 'camera_model',
'city', 'city',
'coordinates',
'country', 'country',
# 'date_created', # 'date_created',
'date_media', 'date_media',
# 'date_modified', # 'date_modified',
'date_original', 'date_original',
'default',
'latitude', 'latitude',
'location', 'location',
'longitude', 'longitude',
@ -405,6 +427,9 @@ def _edit(**kwargs):
keys = set(editable_keys) keys = set(editable_keys)
else: else:
keys = set(kwargs['key']) keys = set(kwargs['key'])
if 'coordinates' in keys:
keys.remove('coordinates')
keys.update(['latitude', 'longitude'])
location = False location = False
for key in keys: for key in keys:
@ -412,10 +437,6 @@ def _edit(**kwargs):
LOG.error(f"key '{key}' is not valid") LOG.error(f"key '{key}' is not valid")
sys.exit(1) sys.exit(1)
if key == 'coordinates':
keys.remove('coordinates')
keys.update(['latitude', 'longitude'])
if key in ( if key in (
'city', 'city',
'latitude', 'latitude',
@ -431,7 +452,7 @@ def _edit(**kwargs):
else: else:
loc = None loc = None
summary = collection.edit_metadata(paths, keys, loc, overwrite=True) summary = collection.edit_metadata(paths, keys, loc, overwrite)
if log_level < 30: if log_level < 30:
summary.print() summary.print()
@ -448,7 +469,7 @@ def _init(**kwargs):
Init media collection database. Init media collection database.
""" """
root = Path(kwargs['path']).expanduser().absolute() root = Path(kwargs['path']).expanduser().absolute()
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
collection = Collection(root) collection = Collection(root)
@ -485,7 +506,7 @@ def _import(**kwargs):
"""Sort files or directories by reading their EXIF and organizing them """Sort files or directories by reading their EXIF and organizing them
according to ordigi.conf preferences. according to ordigi.conf preferences.
""" """
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
src_paths, root = _get_paths(kwargs['src'], kwargs['dest']) src_paths, root = _get_paths(kwargs['src'], kwargs['dest'])
@ -541,7 +562,7 @@ def _sort(**kwargs):
"""Sort files or directories by reading their EXIF and organizing them """Sort files or directories by reading their EXIF and organizing them
according to ordigi.conf preferences. according to ordigi.conf preferences.
""" """
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
paths, root = _get_paths(kwargs['subdirs'], kwargs['dest']) paths, root = _get_paths(kwargs['subdirs'], kwargs['dest'])
@ -592,7 +613,7 @@ def _update(**kwargs):
Update media collection database. Update media collection database.
""" """
root = Path(kwargs['path']).expanduser().absolute() root = Path(kwargs['path']).expanduser().absolute()
log_level = log.get_level(kwargs['verbose']) log_level = log.get_level(kwargs['quiet'], kwargs['verbose'], kwargs['debug'])
log.console(LOG, level=log_level) log.console(LOG, level=log_level)
collection = Collection(root) collection = Collection(root)

View File

@ -494,6 +494,7 @@ class SortMedias:
self.summary = Summary(self.root) self.summary = Summary(self.root)
# Attributes # Attributes
self.input = request.Input()
self.theme = request.load_theme() self.theme = request.load_theme()
def _checkcomp(self, dest_path, src_checksum): def _checkcomp(self, dest_path, src_checksum):
@ -580,14 +581,10 @@ class SortMedias:
self.log.warning(f'Target directory {dir_path} is a file') self.log.warning(f'Target directory {dir_path} is a file')
# Rename the src_file # Rename the src_file
if self.interactive: if self.interactive:
prompt = [ answer = self.input.text(
inquirer.Text( "New name for" f"'{dir_path.name}' file"
'file_path', )
message="New name for" f"'{dir_path.name}' file", file_path = dir_path.parent / answer
),
]
answers = inquirer.prompt(prompt, theme=self.theme)
file_path = dir_path.parent / answers['file_path']
else: else:
file_path = dir_path.parent / (dir_path.name + '_file') file_path = dir_path.parent / (dir_path.name + '_file')
@ -760,6 +757,7 @@ class Collection(SortMedias):
self.paths, self.paths,
root, root,
self.opt['Exif'], self.opt['Exif'],
{},
self.db, self.db,
self.opt['Terminal']['interactive'], self.opt['Terminal']['interactive'],
) )
@ -867,7 +865,20 @@ class Collection(SortMedias):
return True return True
def check_db(self): def check_file(self, file_path):
self.medias.checksums[file_path] = utils.checksum(file_path)
if self._check_file(file_path, self.medias.checksums[file_path]):
return True
# We d'ont want to silently ignore or correct this without
# resetting the cache as is could be due to file corruption
self.log.error(f'modified or corrupted file.')
self.log.info(
'Use ordigi update --checksum or --reset-cache, check database integrity or try to restore the file'
)
return False
def check_db(self, checksums=True):
""" """
Check if db FilePath match to collection filesystem Check if db FilePath match to collection filesystem
:returns: bool :returns: bool
@ -876,18 +887,11 @@ class Collection(SortMedias):
db_rows = [row['FilePath'] for row in self.db.sqlite.get_rows('metadata')] db_rows = [row['FilePath'] for row in self.db.sqlite.get_rows('metadata')]
for file_path in file_paths: for file_path in file_paths:
result = self.file_in_db(file_path, db_rows) result = self.file_in_db(file_path, db_rows)
checksum = utils.checksum(file_path)
if not result: if not result:
self.log.error('Db data is not accurate') self.log.error('Db data is not accurate')
self.log.info(f'{file_path} not in db') self.log.info(f'{file_path} not in db')
return False return False
elif not self._check_file(file_path, checksum): elif checksums and not self.check_file(file_path):
# We d'ont want to silently ignore or correct this without
# resetting the cache as is could be due to file corruption
self.log.error(f'modified or corrupted file.')
self.log.info(
'Use ordigi update --checksum or --reset-cache, check database integrity or try to restore the file'
)
return False return False
nb_files = len(file_paths) nb_files = len(file_paths)
@ -906,10 +910,10 @@ class Collection(SortMedias):
self.log.error('Db data is not accurate run `ordigi update`') self.log.error('Db data is not accurate run `ordigi update`')
sys.exit(1) sys.exit(1)
def _init_check_db(self, loc=None): def _init_check_db(self, checksums=True, loc=None):
if self.db.sqlite.is_empty('metadata'): if self.db.sqlite.is_empty('metadata'):
self.init(loc) self.init(loc)
elif not self.check_db(): elif not self.check_db(checksums):
self.log.error('Db data is not accurate run `ordigi update`') self.log.error('Db data is not accurate run `ordigi update`')
sys.exit(1) sys.exit(1)
@ -938,6 +942,7 @@ class Collection(SortMedias):
db_rows = list(self.db.sqlite.get_rows('metadata')) db_rows = list(self.db.sqlite.get_rows('metadata'))
invalid_db_rows = set() invalid_db_rows = set()
db_paths = set() db_paths = set()
self.log.info(f"Update database:")
for db_row in db_rows: for db_row in db_rows:
abspath = self.root / db_row['FilePath'] abspath = self.root / db_row['FilePath']
if abspath not in file_paths: if abspath not in file_paths:
@ -949,15 +954,17 @@ class Collection(SortMedias):
relpath = os.path.relpath(file_path, self.root) relpath = os.path.relpath(file_path, self.root)
metadata = {} metadata = {}
checksum = utils.checksum(file_path) self.medias.checksums[file_path] = utils.checksum(file_path)
if not self._check_file(file_path, checksum) and update_checksum: if (
not self._check_file(file_path, self.medias.checksums[file_path])
and update_checksum
):
# metatata will fill checksum from file # metatata will fill checksum from file
metadata = self.medias.get_metadata( metadata = self.medias.get_metadata(file_path, self.root, loc=loc)
file_path, self.root, checksum, loc=loc
)
metadata['file_path'] = relpath metadata['file_path'] = relpath
# set row attribute to the file # set row attribute to the file
self.db.add_file_data(metadata) self.db.add_file_data(metadata)
self.log.info(f"Update '{file_path}' checksum to db")
self.summary.append('update', file_path) self.summary.append('update', file_path)
# If file not in database # If file not in database
@ -978,11 +985,13 @@ class Collection(SortMedias):
break break
# set row attribute to the file # set row attribute to the file
self.db.add_file_data(metadata) self.db.add_file_data(metadata)
self.log.info(f"Add '{file_path}' to db")
self.summary.append('update', file_path) self.summary.append('update', file_path)
# Finally delete invalid rows # Finally delete invalid rows
for row in invalid_db_rows: for row in invalid_db_rows:
self.db.sqlite.delete_filepath(row['FilePath']) self.db.sqlite.delete_filepath(row['FilePath'])
self.log.info(f"Delete invalid row : '{row['FilePath']}' from db")
return self.summary return self.summary
@ -1057,7 +1066,7 @@ class Collection(SortMedias):
Sort files into appropriate folder Sort files into appropriate folder
""" """
# Check db # Check db
self._init_check_db(loc) self._init_check_db(loc=loc)
path_format = self.opt['Path']['path_format'] path_format = self.opt['Path']['path_format']
self.log.debug(f'path_format: {path_format}') self.log.debug(f'path_format: {path_format}')
@ -1189,35 +1198,43 @@ class Collection(SortMedias):
def edit_metadata(self, paths, keys, loc=None, overwrite=False): def edit_metadata(self, paths, keys, loc=None, overwrite=False):
"""Edit metadata and exif data for given key""" """Edit metadata and exif data for given key"""
self._init_check_db()
if self.db.sqlite.is_empty('metadata'):
self.init(loc)
for file_path, media in self.medias.get_medias_datas(paths, loc=loc): for file_path, media in self.medias.get_medias_datas(paths, loc=loc):
result = False
media.metadata['file_path'] = os.path.relpath(file_path, self.root) media.metadata['file_path'] = os.path.relpath(file_path, self.root)
if not self.check_file(file_path):
self.log.error('Db data is not accurate run `ordigi update`')
sys.exit(1)
exif = WriteExif(
file_path,
media.metadata,
ignore_tags=self.opt['Exif']['ignore_tags'],
)
for key in keys: for key in keys:
print() print()
value = media.metadata[key] value = media.metadata[key]
if overwrite or not value: if overwrite or not value:
print(f"FILE: '{file_path}'") print(f"FILE: '{file_path}'")
if overwrite: if overwrite and value:
print(f"{key}: '{value}'") print(f"{key}: '{value}'")
if overwrite or not value: if overwrite or not value:
# Prompt value for given key for file_path # Prompt value for given key for file_path
prompt = [ answer = self.input.text(key)
inquirer.Text('value', message=key), # Check value
]
answer = inquirer.prompt(prompt, theme=self.theme)
# answer = {'value': '03-12-2021 08:12:35'}
# Validate value
if key in ('date_original', 'date_created', 'date_modified'): if key in ('date_original', 'date_created', 'date_modified'):
# Check date format # Check date format
value = media.get_date_format(answer['value']) value = media.get_date_format(answer)
else: else:
value = answer['value'] value = answer
if not value.isalnum(): while not value.isalnum():
if not value: break
print("Invalid entry, use alphanumeric chars") print("Invalid entry, use alphanumeric chars")
value = inquirer.prompt(prompt, theme=self.theme) value = inquirer.prompt(prompt, theme=self.theme)
result = False
if value: if value:
media.metadata[key] = value media.metadata[key] = value
if key == 'location': if key == 'location':
@ -1228,40 +1245,25 @@ class Collection(SortMedias):
media.set_location_from_coordinates(loc) media.set_location_from_coordinates(loc)
# Update exif data # Update exif data
if key in ( if key == 'location':
'date_original', result = exif.set_key_values(
'album', 'latitude', media.metadata['latitude']
'title',
'latitude',
'location',
'longitude',
'latitude_ref',
'longitude_ref',
):
exif = WriteExif(
file_path,
media.metadata,
ignore_tags=self.opt['Exif']['ignore_tags'],
) )
if key == 'location': result = exif.set_key_values(
result = exif.set_key_values( 'longitude', media.metadata['longitude']
'latitude', media.metadata['latitude'] )
) elif key in exif.get_tags().keys():
result = exif.set_key_values( result = exif.set_key_values(key, value)
'longitude', media.metadata['longitude']
)
else:
result = exif.set_key_values(key, value)
# Update checksum # Update checksum
media.metadata['checksum'] = utils.checksum(file_path) media.metadata['checksum'] = utils.checksum(file_path)
# Update database # Update database
self.db.add_file_data(media.metadata) self.db.add_file_data(media.metadata)
if result: if result:
self.summary.append('update', True, file_path) self.summary.append('update', True, file_path)
else: else:
self.summary.append('update', False, file_path) self.summary.append('update', False, file_path)
return self.summary return self.summary

View File

@ -46,9 +46,16 @@ def file_logger(logger, file, level=30):
logger.addHandler(handler) logger.addHandler(handler)
def get_level(verbose): def get_level(quiet=False, verbose=False, debug=False, num=None):
"""Return int logging level from string""" """Return int logging level from command line args"""
if verbose.isnumeric(): if num and num.isnumeric():
return int(verbose) return int(verbose)
return int(logging.getLevelName(verbose)) if debug:
return int(logging.getLevelName('DEBUG'))
if verbose:
return int(logging.getLevelName('INFO'))
if quiet:
return int(logging.getLevelName('ERROR'))
return int(logging.getLevelName('WARNING'))

View File

@ -345,11 +345,8 @@ class Media(ReadExif):
sys.exit() sys.exit()
if not answers['date_list']: if not answers['date_list']:
prompt = [ answer = self.prompt.text("date")
inquirer.Text('date_custom', message="date"), return self.get_date_format(answer)
]
answers = inquirer.prompt(prompt, theme=self.theme)
return self.get_date_format(answers['date_custom'])
return answers['date_list'] return answers['date_list']
@ -467,17 +464,12 @@ class Media(ReadExif):
default=f'{album}', default=f'{album}',
), ),
] ]
prompt = [
inquirer.Text('custom', message="album"),
]
answers = inquirer.prompt(choices_list, theme=self.theme) answers = inquirer.prompt(choices_list, theme=self.theme)
if not answers: if not answers:
sys.exit() sys.exit()
if not answers['album']: if not answers['album']:
answers = inquirer.prompt(prompt, theme=self.theme) return self.input.text("album")
return answers['custom']
return answers['album'] return answers['album']
@ -646,6 +638,7 @@ class Medias:
paths, paths,
root, root,
exif_options, exif_options,
checksums=None,
db=None, db=None,
interactive=False, interactive=False,
): ):
@ -658,6 +651,11 @@ class Medias:
self.root = root self.root = root
# Options # Options
if checksums:
self.checksums = checksums
else:
self.checksums = {}
self.exif_opt = exif_options self.exif_opt = exif_options
self.ignore_tags = self.exif_opt['ignore_tags'] self.ignore_tags = self.exif_opt['ignore_tags']
@ -684,7 +682,14 @@ class Medias:
return media return media
def get_media_data(self, file_path, src_dir, checksum=None, loc=None): def get_media_data(self, file_path, src_dir, loc=None):
"""Get media class instance with metadata"""
if self.checksums and file_path in self.checksums.keys():
checksum = self.checksums[file_path]
else:
checksum = None
media = self.get_media(file_path, src_dir, checksum) media = self.get_media(file_path, src_dir, checksum)
media.get_metadata( media.get_metadata(
self.root, loc, self.db.sqlite, self.exif_opt['cache'] self.root, loc, self.db.sqlite, self.exif_opt['cache']
@ -692,9 +697,9 @@ class Medias:
return media return media
def get_metadata(self, src_path, src_dir, checksum=None, loc=None): def get_metadata(self, src_path, src_dir, loc=None):
"""Get metadata""" """Get metadata"""
return self.get_media_data(src_path, src_dir, checksum, loc).metadata return self.get_media_data(src_path, src_dir, loc).metadata
def get_paths(self, src_dirs, imp=False): def get_paths(self, src_dirs, imp=False):
"""Get paths""" """Get paths"""

View File

@ -1,5 +1,6 @@
import inquirer import inquirer
from blessed import Terminal from blessed import Terminal
from colorama import init,Fore,Style,Back
term = Terminal() term = Terminal()
@ -34,6 +35,15 @@ def load_theme():
return inquirer.themes.load_theme_from_dict(custom_theme) return inquirer.themes.load_theme_from_dict(custom_theme)
class Input():
def __init__(self):
init()
def text(self, message):
return input(f'{Fore.BLUE}[{Fore.YELLOW}?{Fore.BLUE}]{Fore.WHITE} {message}: ')
# def edit_prompt(self, key: str, value: str) -> str: # def edit_prompt(self, key: str, value: str) -> str:
# print(f"Date conflict for file: {self.file_path}") # print(f"Date conflict for file: {self.file_path}")

View File

@ -5,6 +5,7 @@ import pytest
import inquirer import inquirer
from ordigi import cli from ordigi import cli
from ordigi.request import Input
CONTENT = "content" CONTENT = "content"
@ -26,7 +27,7 @@ class TestOrdigi:
def setup_class(cls, sample_files_paths): def setup_class(cls, sample_files_paths):
cls.runner = CliRunner() cls.runner = CliRunner()
cls.src_path, cls.file_paths = sample_files_paths cls.src_path, cls.file_paths = sample_files_paths
cls.logger_options = (('--verbose', 'DEBUG'),) cls.logger_options = ('--debug',)
cls.filter_options = ( cls.filter_options = (
('--ignore-tags', 'CreateDate'), ('--ignore-tags', 'CreateDate'),
('--ext', 'jpg'), ('--ext', 'jpg'),
@ -81,21 +82,23 @@ class TestOrdigi:
def test_edit(self, monkeypatch): def test_edit(self, monkeypatch):
bool_options = () bool_options = (
*self.logger_options,
)
arg_options = ( arg_options = (
*self.logger_options,
*self.filter_options, *self.filter_options,
) )
def mockreturn(prompt, theme): def mockreturn(self, message):
return {'value': '03-12-2021 08:12:35'} return '03-12-2021 08:12:35'
monkeypatch.setattr(inquirer, 'prompt', mockreturn) monkeypatch.setattr(Input, 'text', mockreturn)
args = ( args = (
'--key', '--key',
'date_original', 'date_original',
'--overwrite',
str(self.src_path.joinpath('test_exif/photo.png')), str(self.src_path.joinpath('test_exif/photo.png')),
str(self.src_path), str(self.src_path),
) )
@ -107,6 +110,7 @@ class TestOrdigi:
def test_sort(self): def test_sort(self):
bool_options = ( bool_options = (
*self.logger_options,
# '--interactive', # '--interactive',
'--dry-run', '--dry-run',
'--album-from-folder', '--album-from-folder',
@ -117,7 +121,6 @@ class TestOrdigi:
) )
arg_options = ( arg_options = (
*self.logger_options,
*self.filter_options, *self.filter_options,
('--path-format', '{%Y}/{folder}/{name}.{ext}'), ('--path-format', '{%Y}/{folder}/{name}.{ext}'),
@ -132,36 +135,29 @@ class TestOrdigi:
def test_clone(self, tmp_path): def test_clone(self, tmp_path):
arg_options = (
*self.logger_options,
)
paths = (str(self.src_path), str(tmp_path)) paths = (str(self.src_path), str(tmp_path))
self.assert_cli(cli._init, [str(self.src_path)]) self.assert_cli(cli._init, [str(self.src_path)])
self.assert_cli(cli._clone, ['--dry-run', '--verbose', 'DEBUG', *paths]) self.assert_cli(cli._clone, ['--dry-run', *self.logger_options, *paths])
self.assert_cli(cli._clone, paths) self.assert_cli(cli._clone, paths)
def assert_init(self): def assert_init(self):
for opt, arg in self.logger_options: self.assert_cli(cli._init, [*self.logger_options, str(self.src_path)])
self.assert_cli(cli._init, [opt, arg, str(self.src_path)])
def assert_update(self): def assert_update(self):
file_path = Path(ORDIGI_PATH, 'samples/test_exif/photo.cr2') file_path = Path(ORDIGI_PATH, 'samples/test_exif/photo.cr2')
dest_path = self.src_path / 'photo_moved.cr2' dest_path = self.src_path / 'photo_moved.cr2'
shutil.copyfile(file_path, dest_path) shutil.copyfile(file_path, dest_path)
for opt, arg in self.logger_options: self.assert_cli(cli._update, [*self.logger_options, str(self.src_path)])
self.assert_cli(cli._update, [opt, arg, str(self.src_path)])
self.assert_cli(cli._update, ['--checksum', str(self.src_path)]) self.assert_cli(cli._update, ['--checksum', str(self.src_path)])
def assert_check(self): def assert_check(self):
for opt, arg in self.logger_options: self.assert_cli(cli._check, [*self.logger_options, str(self.src_path)])
self.assert_cli(cli._check, [opt, arg, str(self.src_path)])
def assert_clean(self): def assert_clean(self):
bool_options = ( bool_options = (
*self.logger_options,
# '--interactive', # '--interactive',
'--dry-run', '--dry-run',
'--delete-excluded', '--delete-excluded',
@ -171,7 +167,6 @@ class TestOrdigi:
) )
arg_options = ( arg_options = (
*self.logger_options,
*self.filter_options, *self.filter_options,
('--dedup-regex', r'\d{4}-\d{2}'), ('--dedup-regex', r'\d{4}-\d{2}'),
) )
@ -192,6 +187,7 @@ class TestOrdigi:
def test_import(self, tmp_path): def test_import(self, tmp_path):
bool_options = ( bool_options = (
*self.logger_options,
# '--interactive', # '--interactive',
'--dry-run', '--dry-run',
'--album-from-folder', '--album-from-folder',
@ -202,7 +198,6 @@ class TestOrdigi:
) )
arg_options = ( arg_options = (
*self.logger_options,
('--exclude', '.DS_Store'), ('--exclude', '.DS_Store'),
*self.filter_options, *self.filter_options,
('--path-format', '{%Y}/{folder}/{stem}.{ext}'), ('--path-format', '{%Y}/{folder}/{stem}.{ext}'),
@ -218,6 +213,7 @@ class TestOrdigi:
def test_compare(self): def test_compare(self):
bool_options = ( bool_options = (
*self.logger_options,
# '--interactive', # '--interactive',
'--dry-run', '--dry-run',
'--find-duplicates', '--find-duplicates',
@ -225,7 +221,6 @@ class TestOrdigi:
) )
arg_options = ( arg_options = (
*self.logger_options,
*self.filter_options, *self.filter_options,
# ('--similar-to', ''), # ('--similar-to', ''),
('--similarity', '65'), ('--similarity', '65'),

View File

@ -8,13 +8,14 @@ import inquirer
from ordigi import LOG from ordigi import LOG
from ordigi import constants from ordigi import constants
from ordigi import utils
from ordigi.summary import Summary
from ordigi.collection import Collection, FPath, Paths from ordigi.collection import Collection, FPath, Paths
from ordigi.exiftool import ExifTool, ExifToolCaching, exiftool_is_running, terminate_exiftool from ordigi.exiftool import ExifTool, ExifToolCaching, exiftool_is_running, terminate_exiftool
from ordigi.geolocation import GeoLocation from ordigi.geolocation import GeoLocation
from ordigi.media import Media, ReadExif from ordigi.media import Media, ReadExif
from ordigi import utils from ordigi.request import Input
from .conftest import randomize_files, randomize_db from .conftest import randomize_files, randomize_db
from ordigi.summary import Summary
LOG.setLevel(10) LOG.setLevel(10)
@ -169,7 +170,7 @@ class TestCollection:
path_format = 'test_exif/<city>/<%Y>-<name>.%l<ext>' path_format = 'test_exif/<city>/<%Y>-<name>.%l<ext>'
summary = collection.sort_files([tmp_path], loc) summary = collection.sort_files([tmp_path], loc)
self.assert_sort(summary, 26) self.assert_sort(summary, 23)
shutil.copytree(tmp_path / 'test_exif', tmp_path / 'test_exif_copy') shutil.copytree(tmp_path / 'test_exif', tmp_path / 'test_exif_copy')
collection.summary = Summary(tmp_path) collection.summary = Summary(tmp_path)
@ -257,10 +258,10 @@ class TestCollection:
shutil.copytree(self.src_path, path) shutil.copytree(self.src_path, path)
collection = Collection(path, {'cache': False}) collection = Collection(path, {'cache': False})
def mockreturn(prompt, theme): def mockreturn(self, message):
return {'value': '03-12-2021 08:12:35'} return '03-12-2021 08:12:35'
monkeypatch.setattr(inquirer, 'prompt', mockreturn) monkeypatch.setattr(Input, 'text', mockreturn)
collection.edit_metadata({path}, {'date_original'}, overwrite=True) collection.edit_metadata({path}, {'date_original'}, overwrite=True)
# check if db value is set # check if db value is set
@ -278,10 +279,10 @@ class TestCollection:
collection = Collection(path, {'cache': False}) collection = Collection(path, {'cache': False})
loc = GeoLocation() loc = GeoLocation()
def mockreturn(prompt, theme): def mockreturn(self, message):
return {'value': 'lyon'} return 'lyon'
monkeypatch.setattr(inquirer, 'prompt', mockreturn) monkeypatch.setattr(Input, 'text', mockreturn)
collection.edit_metadata({path}, {'location'}, loc, True) collection.edit_metadata({path}, {'location'}, loc, True)
# check if db value is set # check if db value is set

111
todo.md
View File

@ -1,111 +0,0 @@
# NOW
- db integrity have not to be checked in media but in collection??
- build structure to store file path and info with metadata
metadatas[file_path] = {'checksum': value}. Init must select same files than
get_metadatata
- check edit_metadata again test with valid doc
- show exif metadata
- print all values and select some to edit
- dry run = no changes
- compare custom output folder similar to?
- ordigi-gui
- add name and dirpath options???
# TODO
Options:
--location --time
# -f overwrite metadata
--auto|-a: a set of option: geolocalisation, best match date, rename, album
from folder...
# --keep-folder option
# --rename
--confirm unsure operation
# Bugs
- summary
- set date original???, interactive mode...
- Faire en sorte que le programme ne plante pas...
- option to not update exif metadata...
## Exiftools
https://gitlab.com/TNThieding/exif
exiftool -akljklbum=tjkljkestjlj /tmp/pytest-of-cedric/pytest-12/test_sort_files0/2008-10-Oct/test_exif/2008-10-24_09-12-56-photo.nef
exiftool -album=tjkljkestjlj /tmp/pytest-of-cedric/pytest-12/test_sort_files0/2008-10-Oct/test_exif/2008-10-24_09-12-56-photo.nef
1 image files updated
Get result code....
## Doc use sphinx??
## Commands
- ordigi view/show
- ordigi search
- use tree to show paths?
# Pylint
https://pythonspeed.com/articles/pylint/
use config file
# Media:
# Test:
# enhancement
- summary: replace success by copied/moved/deleted
## Alias
alias ogi=ordigi
## Image analysis
https://pypi.org/project/google-cloud-vision/
https://googleapis.dev/python/vision/latest/index.html
https://www.datacamp.com/community/tutorials/beginner-guide-google-vision-api
## Album form folder
# Update
https://github.com/JohannesBuchner/imagehash
https://github.com/cw-somil/Duplicate-Remover
https://leons.im/posts/a-python-implementation-of-simhash-algorithm/
Visualy check similar image
https://www.pluralsight.com/guides/importing-image-data-into-numpy-arrays
https://stackoverflow.com/questions/56056054/add-check-boxes-to-scrollable-image-in-python
https://wellsr.com/python/python-image-manipulation-with-pillow-library/
kitty gird image?
https://fr.wikibooks.org/wiki/PyQt/PyQt_versus_wxPython
https://docs.python.org/3/faq/gui.html
https://docs.opencv.org/3.4/d3/df2/tutorial_py_basic_ops.html
https://stackoverflow.com/questions/52727332/python-tkinter-create-checkbox-list-from-listbox
Image gird method:
matplot
https://gist.github.com/lebedov/7018889ba47668c64bcf96aee82caec0
Tkinter
https://python-forum.io/thread-22700.html
https://stackoverflow.com/questions/43326282/how-can-i-use-images-in-a-tkinter-grid
wxwidget
https://wxpython.org/Phoenix/docs/html/wx.lib.agw.thumbnailctrl.html
Ability to change metadata to selection
Fix: change versvalidion number to 0.x99
https://github.com/andrewning/sortphotos/blob/master/src/sortphotos.py
# AFTER

View File

@ -1,30 +0,0 @@
# Create virtual environment
nmkvirtualenv ordigi
# Work on it (activate and cd)
workon ordigi
# Install required dependencies
pip install -r requirements.txt
# Liked it to path
pip install -e .
# View file tree of path
tree /dest/path
# Test code
pylint ordigi/* -E
pylint ordigi/**
pytest --cov=ordigi --cov-report html tests/*.py
pip install --prefix=~/.local -e .
# config
## Path format
dirs_path=<%Y>/<%m-%b>-<city>-<folder>
name=<%Y%m%d-%H%M%S>-%u<original_name>|%u<basename>.%l<ext>
## run
ordigi import 220719.bkp -f -c -R collection