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 # imgsort - Image Sorter
This is a python program that lets you easily sort images from one directory into other directories. This is a python program for Linux 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. 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 ## Usage
1. Navigate to the folder containing the images and run "imgsort". 1. Navigate to the folder containing the images and run "imgsort".
@ -12,24 +16,41 @@ imgsort
For example, you could use: For example, you could use:
- `f` = `~/Pictures/Family` - `f` = `~/Pictures/Family`
- `v` = `~/Pictures/Vacation_2019` - `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. 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 `~/.config/imgsort`. 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! 4. Enjoy the slideshow!
## Installation ## Installation
Clone this repository and install it using python-pip. 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 ```shell
cd ~/Downloads
git clone https://github.com/MatthiasQuintern/imgsort.git git clone https://github.com/MatthiasQuintern/imgsort.git
cd imgsort cd imgsort
python3 -m pip install . 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 ## 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 ### 1.1
- Terminal does not break anymore when program exits - Terminal does not break anymore when program exits
- Todo-Images are now sorted by filename - 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 ### 1.0
- Initial Release - Initial Release
## Importand Notice: ## Important Notice:
This software comes with no warranty! 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 import re
def read_config(filepath): from .globals import version, settings_map
if not path.isfile(filepath): return False from .globals import warning, error, user_error, info, create_dir
file = open(filepath, 'r') class ConfigManager():
keys = {} """Manage config files for imgsort"""
for line in file.readlines(): def __init__(self, config_dir: str):
line = line.replace("\n", "") """TODO: to be defined.
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
def write_config(filepath, keys): @param config_path TODO
file = open(filepath, 'w')
file.write("Config written by imgsort.\n")
for k, v in keys.items() :
file.write(f"{k} = {v}\n")
def create_config(): """
keys = {} self._config_dir = config_dir
print( 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.")
Creating a new config info(f"Creating config dir '{self._config_dir}' since it does not exist")
=================================================================================================== try:
Please enter at least one key and one directory. create_dir(self._config_dir)
The key must be one single letter, a single digit number or some other keyboard key like .-#+&/ ... except PermissionError as e:
The key can not be 'q', 's' or 'u'. error(f"Could not create '{self._config_dir}': PermissionError: {e}")
The directory must be a valid path to a directory, but is does not have to exist. except Exception as e:
You can use an absolute path (starting with '/') or a relative path (from here). error(f"Could not create '{self._config_dir}': {e}")
Do not use '~'!
"""
)
done = False self._configs = [ e for e in listdir(self._config_dir) if path.isfile(path.normpath(self._config_dir + "/" + e)) and e.endswith(".conf") ]
while not done: self._configs.sort()
# ask for key def present_config_selection(self, root_directory="."):
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)
write_config(path.normpath(config_path + "/" + save + ".conf"), keys)
done = True
continue
# 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
def select_config():
""" """
Returns to path to an existing config or False if a new config should be created Returns to path to an existing config or False if a new config should be created
""" """
# get configs # get configs
config_path = path.expanduser("~") + "/.config/imgsort" if len(self._configs) == 0:
if not path.isdir(config_path) or len(listdir(config_path)) == 0: info(f"No config valid file found in '{self._config_dir}'")
return False return self.create_config(root_directory)
configs = {} print(" 0: create new configuration")
for i, c in enumerate(self._configs):
i = 1 print(f"{i+1:2}: {c}")
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}")
while True:
choice = input("Please select a config: ") choice = input("Please select a config: ")
if choice == "0": return False try:
elif choice in configs: choice = int(choice)
return path.normpath(config_path + "/" + configs[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).
===================================================================================================
"""
)
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 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")
self.validate_config(keys, root_directory)
return keys
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: else:
print("Invalid choice - creating new config") error("Exiting - can not use non-existing directories.")
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 #!/bin/python3
import argparse
import curses as c 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 import ueberzug.lib.v0 as uz
from imgsort.configs import read_config, write_config, select_config, create_config from .configs import ConfigManager
from .globals import version, fullversion, settings_map
from os import path, getcwd, listdir, mkdir, makedirs, rename from .globals import warning, error, user_error, info, create_dir
from sys import argv
settings = {
"q": "quit",
"s": "skip",
"u": "undo",
}
# Size settings # Size settings
FOOTER_LEFT = 0 FOOTER_LEFT = 0
@ -37,9 +41,7 @@ class Sorter:
self.keys = config self.keys = config
self.settings = settings self.settings = settings_map
self.validate_dirs()
# info about last action # info about last action
self.last_dir = "" self.last_dir = ""
@ -66,24 +68,6 @@ class Sorter:
self.placement.width = self.win_x - SIDEBAR_WIDTH - 1 self.placement.width = self.win_x - SIDEBAR_WIDTH - 1
self.placement.height = self.win_y - FOOTER_HEIGHT - 2 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): def get_images(self):
""" """
Put all image-paths from wd in images dictionary. Put all image-paths from wd in images dictionary.
@ -117,7 +101,7 @@ class Sorter:
self.pressed_key = self.window.getkey() # wait until user presses something 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.pressed_key in self.settings:
if self.settings[self.pressed_key] == "quit": if self.settings[self.pressed_key] == "quit":
self.quit(f"Key '{self.pressed_key}' pressed. Canceling image sorting") self.quit(f"Key '{self.pressed_key}' pressed. Canceling image sorting")
@ -125,7 +109,7 @@ class Sorter:
self.image_iter += 1 self.image_iter += 1
self.message = "Skipped image" self.message = "Skipped image"
continue continue
elif settings[self.pressed_key] == "undo": elif settings_map[self.pressed_key] == "undo":
if self.image_iter > 0: if self.image_iter > 0:
self.image_iter -= 1 # using last image self.image_iter -= 1 # using last image
rename(self.images_new[self.image_iter], self.images[self.image_iter]) rename(self.images_new[self.image_iter], self.images[self.image_iter])
@ -135,6 +119,13 @@ class Sorter:
else: else:
self.message = "Nothing to undo!" self.message = "Nothing to undo!"
continue 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 # move to folder
elif self.pressed_key in self.keys: elif self.pressed_key in self.keys:
@ -143,7 +134,7 @@ class Sorter:
self.images_new[self.image_iter] = new_filepath self.images_new[self.image_iter] = new_filepath
self.message = f"Moved image to {self.keys[self.pressed_key]}" self.message = f"Moved image to {self.keys[self.pressed_key]}"
else: 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.image_iter += 1
self.quit("All done!") self.quit("All done!")
@ -153,14 +144,16 @@ class Sorter:
Draw lines and text Draw lines and text
""" """
self.window.erase() self.window.erase()
self.win_y, self.win_x = self.window.getmaxyx()
# lines # lines
self.window.hline(self.win_y - FOOTER_HEIGHT, FOOTER_LEFT, '=', self.win_x) 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) self.window.vline(0, SIDEBAR_WIDTH, '|', self.win_y - FOOTER_HEIGHT + 1)
# version # version
x = self.win_x - len(self.version) - 1 version_str = f"imgsort {version}"
self.window.addstr(self.win_y - 1, x, self.version) x = self.win_x - len(version_str) - 1
self.window.addstr(self.win_y - 1, x, version_str)
# wd # wd
wdstring = f"Sorting {self.wd} - {len(self.images)} files - {len(self.images) - self.image_iter} remaining." 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(): for k, v in self.keys.items():
if i >= self.win_y - KEYS_BEGIN - FOOTER_HEIGHT: # dont write into footer if i >= self.win_y - KEYS_BEGIN - FOOTER_HEIGHT: # dont write into footer
break break
# show only last part
v = v.split("/")[-1]
if k == self.pressed_key: if k == self.pressed_key:
self.window.addnstr(KEYS_BEGIN + i, 0, f" {k}: {v}", SIDEBAR_WIDTH, c.A_STANDOUT) self.window.addnstr(KEYS_BEGIN + i, 0, f" {k}: {v}", SIDEBAR_WIDTH, c.A_STANDOUT)
else: else:
@ -222,39 +217,46 @@ class Sorter:
return 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.clear()
self.window.refresh() self.window.refresh()
c.endwin() c.endwin()
print(message)
print("Quitting " + self.version)
exit(0)
def main(): def main():
# set working directory # set working directory
print(""" print(f"""
=================================================================================================== ===================================================================================================
Image Sorter Image Sorter {fullversion}
=================================================================================================== ===================================================================================================
""") """)
if len(argv) > 1: config_dir = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "imgsort")
wd = path.abspath(argv[1]) # check if environment variables are set and use them if they are
else: if 'IMGSORT_CONFIG_DIR' in os.environ: config_dir = os.environ['IMGSORT_CONFIG_DIR']
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()
wd = getcwd(); wd = getcwd();
config_name = select_config() if args.sort_dir:
if type(config_name) == str: args.sort_dir = path.abspath(args.sort_dir)
config = read_config(config_name)
else: else:
config = create_config() args.sort_dir = getcwd()
if not config: confman = ConfigManager(config_dir)
print("Error reading the config:")
print(" Config Name:", config_name) # configuration
print(" Config:", config) if type(args.config) == str:
exit(1) config = confman.load_config(args.config, args.sort_dir)
else:
config = confman.present_config_selection(args.sort_dir)
with uz.Canvas() as canvas: with uz.Canvas() as canvas:
sorter = Sorter(wd, canvas, config) sorter = Sorter(wd, canvas, config)

View File

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