Compare commits

...

22 Commits

Author SHA1 Message Date
cd541d23e4 fix --sort-dir not respected 2024-02-07 11:23:22 +01:00
2122627fdf change exit code 2024-02-07 11:23:15 +01:00
b83a400732 rm pycache 2023-12-16 00:48:35 +01:00
c305165edb improve dependecy info 2023-12-16 00:34:38 +01:00
ef48a089cf configman always return keys 2023-12-16 00:24:44 +01:00
d78f522743 bumb version 2023-12-16 00:24:27 +01:00
15578b4d3c dont exit on user error 2023-12-16 00:01:29 +01:00
8f8e6bdbc5 rm false import 2023-12-16 00:00:11 +01:00
217a034fbf refactor with configmanager 2023-12-15 23:56:45 +01:00
8eb54555f5 move shared variables 2023-12-15 23:50:54 +01:00
643673317b dont use o in example 2023-12-01 21:34:44 +01:00
4eba27bc89 unify style with ++ variant 2023-12-01 21:31:05 +01:00
07859d6682 merge fixes 2023-12-01 21:10:02 +01:00
56f5423766 use argparse and allow sort into other dir 2023-12-01 21:09:54 +01:00
Matthias Quintern
4dcda19614 Center image 2023-11-01 01:22:07 +01:00
matthias@arch
a1a0f52191 add example image 2023-11-01 01:22:07 +01:00
matthias@arch
195877b37a fix typo 2023-10-31 17:56:27 +01:00
matthias@arch
944d69505c fix head 2023-10-23 00:44:39 +02:00
Matthias@Dell
b1d3d76755 add xdg-open 2023-10-23 00:36:10 +02:00
Matthias@Dell
abdf937968 fix window resize issue 2023-10-23 00:18:04 +02:00
Matthias@Dell
17d9d1df43 use argparse, add xdg-open 2023-10-23 00:17:58 +02:00
Matthias@Dell
be6dc9f224 improve error handling 2023-10-23 00:06:56 +02:00
8 changed files with 282 additions and 159 deletions

View File

@ -1,6 +1,10 @@
# imgsort - Image Sorter
This is a python program that lets you easily sort images from one directory into other directories.
For example, you could go through your phone's camera folder and sort the images into different folders, like *Family*, *Landscapes*, *Friends* etc.
This is a python program for Linux that lets you easily sort images from one directory into other directories.
It lets you go through a folder of images and simply move them using a single key press, which you define at program startup.
This is very useful when you want to sort your phone's camera folder or messenger media folders.
For example, you could quickly go through your WhatsApp media (after copying it to your pc) and sort the images into different directories like *Selfies*, *Landscapes*, *Friends* etc.
<img src="imgsort-example.jpg" width="70%" style="margin-left: auto; margin-right: auto;" />
## Usage
1. Navigate to the folder containing the images and run "imgsort".
@ -12,24 +16,41 @@ imgsort
For example, you could use:
- `f` = `~/Pictures/Family`
- `v` = `~/Pictures/Vacation_2019`
- `o` = `~/Pictures/Other`
- `O` = `~/Pictures/Other`
Note that `s`, `u` and `q` are reserved for *skip*, *undo* and *quit*, but you can use `S`, `U` and `Q` instead.
3. Save the config if you might want to use it again. The config file will be stored in `~/.config/imgsort`.
Note that `s`, `u`, `o` and `q` are reserved for *skip*, *undo*, *open* and *quit*, but you can use `S`, `U` and `Q` instead.
3. Save the config if you might want to use it again. The config file will be stored in `$XDG_CONFIG_DIR` or `~/.config/imgsort`.
4. Enjoy the slideshow!
## Installation
Clone this repository and install it using python-pip.
pip should also install https://github.com/seebye/ueberzug, which lets you view images in a terminal.
This project depends on ueberzug to display the images in the terminal.
The original ueberzug is no longer maintained, but there is [a continuation](https://github.com/ueber-devel/ueberzug/) as well as a [new C++ alternative](https://github.com/jstkdng/ueberzugpp) available.
You need to manually install one of them and then choose the corresponding `imgsort` branch.
I would recommend the `ueberzugpp` as it also works on Wayland.
For the version supporting the original **ueberzug**:
```shell
cd ~/Downloads
git clone https://github.com/MatthiasQuintern/imgsort.git
cd imgsort
python3 -m pip install .
```
You can also install it system-wide using `sudo python3 -m pip install.`
For the version supporting the new **ueberzug++**:
```shell
git clone --branch ueberzugpp https://github.com/MatthiasQuintern/imgsort.git
cd imgsort
python3 -m pip install .
```
## Changelog
### 1.2
#### 1.2.1
- Refactored configuration management
#### 1.2.0
- Support ueberzugpp
- Added option to open file with `xdg-open`
- Use pyproject.toml for installation
### 1.1
- Terminal does not break anymore when program exits
- Todo-Images are now sorted by filename
@ -37,5 +58,5 @@ You can also install it system-wide using `sudo python3 -m pip install.`
### 1.0
- Initial Release
## Importand Notice:
## Important Notice:
This software comes with no warranty!

BIN
imgsort-example.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -1,101 +1,169 @@
from os import path, getcwd, listdir, mkdir, makedirs, rename
import enum
from os import EX_CANTCREAT, path, getcwd, listdir, rename
from sys import exit
import re
def read_config(filepath):
if not path.isfile(filepath): return False
file = open(filepath, 'r')
keys = {}
for line in file.readlines():
line = line.replace("\n", "")
match = re.match(r". = /?([a-z-A-ZöÖäÄüÜ0-9/: _-]+/)*[a-zA-ZöÖäÄüÜ0-9/: _-]+/?", line)
if match:
key, value = line.split(" = ")
keys[key] = value
return keys
from .globals import version, settings_map
from .globals import warning, error, user_error, info, create_dir
def write_config(filepath, keys):
file = open(filepath, 'w')
file.write("Config written by imgsort.\n")
for k, v in keys.items() :
file.write(f"{k} = {v}\n")
class ConfigManager():
"""Manage config files for imgsort"""
def __init__(self, config_dir: str):
"""TODO: to be defined.
def create_config():
keys = {}
print(
"""
@param config_path TODO
"""
self._config_dir = config_dir
if not path.isdir(self._config_dir):
if path.exists(self._config_dir):
error(f"Config '{self._config_dir}' exists but is not a directory.")
info(f"Creating config dir '{self._config_dir}' since it does not exist")
try:
create_dir(self._config_dir)
except PermissionError as e:
error(f"Could not create '{self._config_dir}': PermissionError: {e}")
except Exception as e:
error(f"Could not create '{self._config_dir}': {e}")
self._configs = [ e for e in listdir(self._config_dir) if path.isfile(path.normpath(self._config_dir + "/" + e)) and e.endswith(".conf") ]
self._configs.sort()
def present_config_selection(self, root_directory="."):
"""
Returns to path to an existing config or False if a new config should be created
"""
# get configs
if len(self._configs) == 0:
info(f"No config valid file found in '{self._config_dir}'")
return self.create_config(root_directory)
print(" 0: create new configuration")
for i, c in enumerate(self._configs):
print(f"{i+1:2}: {c}")
while True:
choice = input("Please select a config: ")
try:
choice = int(choice)
except ValueError:
user_error(f"Invalid choice: '{choice}'. Choice must be a number between 0 and {len(self._configs)}")
continue
if not 0 <= choice <= len(self._configs):
user_error(f"Invalid choice: '{choice}'. Choice must be a number between 0 and {len(self._configs)}")
continue
if choice == 0:
return self.create_config(root_directory)
return self.load_config(self._configs[choice-1], root_directory)
def _make_name(self, config_name: str):
return path.normpath(self._config_dir + "/" + config_name.removesuffix(".conf") + ".conf")
def write_config(self, config_name: str, keys: dict[str,str]):
file = open(path.normpath(self._config_dir + "/" + config_name), 'w')
file.write(f"# Config written by imgsort {version}\n")
for k, v in keys.items() :
file.write(f"{k} = {v}\n")
def load_config(self, config_name: str, root_directory="."):
"""
@param root_directory Make all relative paths relative to this one
"""
if type(config_name) != str:
error(f"load config got wrong type: '{type(config_name)}'")
config_file = self._make_name(config_name)
if not path.isfile(config_file):
error(f"File '{config_file}' does not exist")
try:
file = open(config_file, 'r')
except Exception as e:
error(f"Could not open file '{config_file}': {e}")
keys: dict[str, str] = {}
for i, line in enumerate(file.readlines()):
line = line.replace("\n", "")
match = re.fullmatch(r". = [^*?<>|]+", line)
if match:
key, value = line.split(" = ")
keys[key] = value
elif not line[0] == "#":
warning(f"In config file '{config_file}': Invalid line ({i+1}): '{line}'")
self.validate_config(keys, root_directory)
return keys
def create_config(self, root_directory="."):
keys: dict[str, str] = {}
print(
f"""
===================================================================================================
Creating a new config
You can now map keys to directories.
The key must be one single letter, a single digit number or some other keyboard key like .-#+&/ ...
The key can not be one of '{' '.join(settings_map.keys())}'.
The directory must be a valid path to a directory, but is does not have to exist.
You can use an absolute path (starting with '/', not '~') or a relative path (from here).
===================================================================================================
Please enter at least one key and one directory.
The key must be one single letter, a single digit number or some other keyboard key like .-#+&/ ...
The key can not be 'q', 's' or 'u'.
The directory must be a valid path to a directory, but is does not have to exist.
You can use an absolute path (starting with '/') or a relative path (from here).
Do not use '~'!
"""
)
)
done = False
while not done:
while True:
# ask for key
key = input("Please enter a key or 'q' when you are done: ")
if (len(key) != 1):
user_error(f"Invalid key: '{key}' has a length other than 1")
continue
# if done
elif key == 'q':
if len(keys) == 0:
warning(f"No keys were mapped - exiting")
exit(0)
save = input(f"\nDo you want to save the config to {self._config_dir}/<name>.conf?\nType a name to save the config or type 'q' to not save the config: ")
if save != 'q':
self.write_config(save + ".conf", keys)
break
elif key in settings_map.keys():
user_error(f"Invalid key: '{key}' is reserved and can not be mapped")
continue
# ask for key
key = input("Please enter a key or 'q' when you are done: ")
if (len(key) != 1):
print("Invalid key: " + key)
continue
# if done
elif key == 'q':
save = input("\nDo you want to save the config to ~/.config/imgsort/<name>.conf?\nType a name to save the config or type 'q' to not save the config: ")
if not save == 'q':
config_path = path.expanduser("~") + "/.config/imgsort"
if not path.isdir(config_path):
mkdir(config_path)
# ask for directory
directory = input("Please enter the directory/path: ")
# match = re.match(r"/?([a-z-A-ZöÖäÄüÜ0-9/: _\-]+/)*[a-z-A-ZöÖäÄüÜ0-9/: _\-]+/?", directory)
INVALID_PATH_CHARS = r":*?<>|"
if any(c in INVALID_PATH_CHARS for c in directory):
user_error(f"Invalid directory path: '{directory}' contains at least one of '{INVALID_PATH_CHARS}'")
continue
keys[key] = directory
print(f"Added: {key}: '{directory}'\n")
write_config(path.normpath(config_path + "/" + save + ".conf"), keys)
done = True
continue
self.validate_config(keys, root_directory)
# ask for directory
directory = input("Please enter the directory path: ")
match = re.match(r"/?([a-z-A-ZöÖäÄüÜ0-9/: _\-]+/)*[a-z-A-ZöÖäÄüÜ0-9/: _\-]+/?", directory)
if not match:
print("Invalid directory path: " + directory)
continue
keys[key] = directory
print(f"Added: ({key}: {directory})\n")
return keys
return keys
def select_config():
"""
Returns to path to an existing config or False if a new config should be created
"""
# get configs
config_path = path.expanduser("~") + "/.config/imgsort"
if not path.isdir(config_path) or len(listdir(config_path)) == 0:
return False
configs = {}
def validate_config(self, keys, root_directory):
"""
Create the directories that dont exist.
"""
missing = []
for k, d in keys.items():
d = path.expanduser(d)
if not path.isabs(d):
d = path.normpath(root_directory + "/" + d)
keys[k] = d
if not path.isdir(d):
missing.append(d)
if len(missing) == 0: return
print(f"The following directories do not exist:")
for d in missing: print(f"\t{d}")
decision = input(f"Create the ({len(missing)}) missing directories? y/*: ")
if (decision == "y"):
for d in missing:
create_dir(d)
else:
error("Exiting - can not use non-existing directories.")
i = 1
for file in listdir(config_path):
if not re.match(r"[a-zA-ZöÖäÄüÜ0-9_\- ]+\.conf", file): continue
configs[str(i)] = file
i += 1
# print configs
print("0: Create new config")
for n, conf in configs.items():
print(f"{n}: {conf}")
choice = input("Please select a config: ")
if choice == "0": return False
elif choice in configs:
return path.normpath(config_path + "/" + configs[choice])
else:
print("Invalid choice - creating new config")
return False

32
imgsort/globals.py Normal file
View File

@ -0,0 +1,32 @@
version = "1.2.1"
fullversion = f"{version}-legacy"
settings_map = {
"q": "quit",
"s": "skip",
"u": "undo",
"o": "open"
}
from os import makedirs
def error(*args, exitcode=2, **kwargs):
print("\033[31mError: \033[0m", *args, **kwargs)
exit(exitcode)
def user_error(*args, **kwargs):
print("\033[31mError: \033[0m", *args, **kwargs)
def warning(*args, **kwargs):
print("\033[33mWarning: \033[0m", *args, **kwargs)
def info(*args, **kwargs):
print("\033[34mInfo: \033[0m", *args, **kwargs)
def create_dir(d):
try:
makedirs(d)
except PermissionError as e:
error(f"Could not create '{d}': PermissionError: {e}")
except Exception as e:
error(f"Could not create '{d}': {e}")

View File

@ -1,19 +1,23 @@
#!/bin/python3
import argparse
import curses as c
import os
from os import path, getcwd, listdir, makedirs, rename
import subprocess
if __name__ == "__main__": # make relative imports work as described here: https://peps.python.org/pep-0366/#proposed-change
if __package__ is None:
__package__ = "imgsort"
import sys
filepath = path.realpath(path.abspath(__file__))
sys.path.insert(0, path.dirname(path.dirname(filepath)))
import ueberzug.lib.v0 as uz
from imgsort.configs import read_config, write_config, select_config, create_config
from os import path, getcwd, listdir, mkdir, makedirs, rename
from sys import argv
settings = {
"q": "quit",
"s": "skip",
"u": "undo",
}
from .configs import ConfigManager
from .globals import version, fullversion, settings_map
from .globals import warning, error, user_error, info, create_dir
# Size settings
FOOTER_LEFT = 0
@ -37,9 +41,7 @@ class Sorter:
self.keys = config
self.settings = settings
self.validate_dirs()
self.settings = settings_map
# info about last action
self.last_dir = ""
@ -66,24 +68,6 @@ class Sorter:
self.placement.width = self.win_x - SIDEBAR_WIDTH - 1
self.placement.height = self.win_y - FOOTER_HEIGHT - 2
# version
self.version = "Image Sorter 1.1"
def validate_dirs(self):
"""
Create the directories that dont exist.
"""
for d in self.keys.values():
if not path.isdir(d):
print(f"Directory '{d}' does not exist.")
decision = input(f"Create directory '{path.abspath(d)}'? y/n: ")
if (decision == "y"):
makedirs(d)
else:
print("Exiting - can not use non-existing directory.")
exit(1)
def get_images(self):
"""
Put all image-paths from wd in images dictionary.
@ -99,7 +83,7 @@ class Sorter:
self.images.sort()
self.images_new = self.images.copy()
# print(self.images)
def display_image(self):
with self.canvas.lazy_drawing: # issue ueberzug command AFTER with-statement
self.placement.path = self.image
@ -117,7 +101,7 @@ class Sorter:
self.pressed_key = self.window.getkey() # wait until user presses something
# check for quit, skip or undo
# check for quit, skip, undo or open
if self.pressed_key in self.settings:
if self.settings[self.pressed_key] == "quit":
self.quit(f"Key '{self.pressed_key}' pressed. Canceling image sorting")
@ -125,7 +109,7 @@ class Sorter:
self.image_iter += 1
self.message = "Skipped image"
continue
elif settings[self.pressed_key] == "undo":
elif settings_map[self.pressed_key] == "undo":
if self.image_iter > 0:
self.image_iter -= 1 # using last image
rename(self.images_new[self.image_iter], self.images[self.image_iter])
@ -135,6 +119,13 @@ class Sorter:
else:
self.message = "Nothing to undo!"
continue
elif settings_map[self.pressed_key] == "open":
try:
subprocess.run(['xdg-open', self.image], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
self.message = "Opening with xdg-open"
except Exception as e:
print(f"open: Error: {e}")
continue
# move to folder
elif self.pressed_key in self.keys:
@ -143,24 +134,26 @@ class Sorter:
self.images_new[self.image_iter] = new_filepath
self.message = f"Moved image to {self.keys[self.pressed_key]}"
else:
self.message = f"ERROR: Failed to move '{self.image}' to '{keys[self.pressed_key]}'."
self.message = f"ERROR: Failed to move '{self.image}' to '{self.keys[self.pressed_key]}'."
self.image_iter += 1
self.quit("All done!")
def print_window(self):
"""
Draw lines and text
"""
self.window.erase()
self.win_y, self.win_x = self.window.getmaxyx()
# lines
self.window.hline(self.win_y - FOOTER_HEIGHT, FOOTER_LEFT, '=', self.win_x)
self.window.vline(0, SIDEBAR_WIDTH, '|', self.win_y - FOOTER_HEIGHT + 1)
# version
x = self.win_x - len(self.version) - 1
self.window.addstr(self.win_y - 1, x, self.version)
version_str = f"imgsort {version}"
x = self.win_x - len(version_str) - 1
self.window.addstr(self.win_y - 1, x, version_str)
# wd
wdstring = f"Sorting {self.wd} - {len(self.images)} files - {len(self.images) - self.image_iter} remaining."
@ -202,6 +195,8 @@ class Sorter:
for k, v in self.keys.items():
if i >= self.win_y - KEYS_BEGIN - FOOTER_HEIGHT: # dont write into footer
break
# show only last part
v = v.split("/")[-1]
if k == self.pressed_key:
self.window.addnstr(KEYS_BEGIN + i, 0, f" {k}: {v}", SIDEBAR_WIDTH, c.A_STANDOUT)
else:
@ -209,52 +204,59 @@ class Sorter:
i += 1
self.window.move(CURSOR_Y, CURSOR_X)
def move_file(self, file, dest):
# if not path.isdir(dest):
# makedirs(dest)
if not path.isfile(file): return False
if not path.isdir(dest): return False
new_path = path.normpath(dest + '/' + path.split(file)[1])
rename(file, new_path)
return new_path
def quit(self, message = ""):
def quit(self, message = ""):
print(message)
print(f"Quitting imgsort {version}")
exit(0)
def __del__(self):
self.window.clear()
self.window.refresh()
c.endwin()
print(message)
print("Quitting " + self.version)
exit(0)
def main():
# set working directory
print("""
print(f"""
===================================================================================================
Image Sorter
Image Sorter {fullversion}
===================================================================================================
""")
if len(argv) > 1:
wd = path.abspath(argv[1])
else:
wd = getcwd();
config_dir = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "imgsort")
# check if environment variables are set and use them if they are
if 'IMGSORT_CONFIG_DIR' in os.environ: config_dir = os.environ['IMGSORT_CONFIG_DIR']
config_name = select_config()
if type(config_name) == str:
config = read_config(config_name)
else:
config = create_config()
parser = argparse.ArgumentParser("imgsort")
parser.add_argument("-c", "--config", action="store", help="name of the config file in ($IMGSORT_CONFIG_DIR > $XDG_CONFIG_HOME/imgsort > ~/.config/imgsort)", default=None)
parser.add_argument("-i", "--sort-dir", action="store", help="directory in which the subdirectories from a config are created")
args = parser.parse_args()
if not config:
print("Error reading the config:")
print(" Config Name:", config_name)
print(" Config:", config)
exit(1)
wd = getcwd();
if args.sort_dir:
args.sort_dir = path.abspath(args.sort_dir)
else:
args.sort_dir = getcwd()
confman = ConfigManager(config_dir)
# configuration
if type(args.config) == str:
config = confman.load_config(args.config, args.sort_dir)
else:
config = confman.present_config_selection(args.sort_dir)
with uz.Canvas() as canvas:
sorter = Sorter(wd, canvas, config)

View File

@ -3,7 +3,7 @@ requires = ["setuptools"]
[project]
name = "imgsort"
version = "1.2.0"
version = "1.2.1"
description = "A program that lets you easily sort images into different folders."
requires-python = ">=3.10"
readme = "README.md"