refactor: provide a proper TUI interface with textual
This commit is contained in:
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
A script to randomly select files from a directory, open them, and allow the user to upload them to a Discord webhook, delete them, or skip to the next file.
|
||||
|
||||
You can specify a file size limit, whether to descend into subdirectories, and provide blacklist/whitelist patterns for file selection.
|
||||
|
||||
Here's an example usage:
|
||||
./rand.py -fl 5 -d /path/to/directory --descend --blacklist "*.png" "*.mp4"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
from random_file_picker.core import collect_files, move_file_to_meme_folder, upload_file
|
||||
from random_file_picker.openers import open_file_with_geeqie, open_path_in_dolphin, open_path_with_default_app
|
||||
|
||||
IMG_EXTENSIONS = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".png",
|
||||
".gif",
|
||||
]
|
||||
|
||||
def getch() -> str:
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(sys.stdin.fileno())
|
||||
ch = sys.stdin.read(1)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
return ch
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Random File Sender")
|
||||
parser.add_argument('-fl', '--file-limit', type=int, default=10, help='Maximum file size in MB')
|
||||
parser.add_argument('-d', '--directory', type=str, default='.', help='Directory to search for files')
|
||||
parser.add_argument('--descend', action='store_true', help='Whether to descend into subdirectories')
|
||||
parser.add_argument('--use-geeqie', action='store_true', help='Whether to use Geeqie as the image viewer')
|
||||
|
||||
parser._positionals.title = 'Positional arguments'
|
||||
parser._optionals.title = 'Optional arguments'
|
||||
|
||||
# Globs for blacklisting and whitelisting, --blacklist "*.png" "*.mp4"
|
||||
parser.add_argument('--blacklist', nargs='*', help='List of glob patterns to blacklist files')
|
||||
parser.add_argument('--whitelist', nargs='*', help='List of glob patterns to whitelist files')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
file_limit: int = args.file_limit
|
||||
directory: str = args.directory
|
||||
descend: bool = args.descend
|
||||
blacklist_patterns: list[str] = args.blacklist if args.blacklist else []
|
||||
whitelist_patterns: list[str] = args.whitelist if args.whitelist else []
|
||||
use_geeqie: bool = args.use_geeqie
|
||||
print(f"File size limit set to {file_limit} MB")
|
||||
|
||||
search_path = pathlib.Path(directory)
|
||||
if not search_path.exists() or not search_path.is_dir():
|
||||
print(f"Directory '{directory}' does not exist or is not a directory.")
|
||||
return
|
||||
|
||||
usable_files = collect_files(
|
||||
search_path=search_path,
|
||||
search_subdirectories=descend,
|
||||
file_size_limit=file_limit,
|
||||
blacklist_patterns=blacklist_patterns,
|
||||
whitelist_patterns=whitelist_patterns,
|
||||
)
|
||||
if not usable_files:
|
||||
print("No usable files found.")
|
||||
return
|
||||
|
||||
pickFile = True
|
||||
while pickFile:
|
||||
file = random.choice(usable_files)
|
||||
|
||||
print(f"Random file: {file}")
|
||||
if use_geeqie and str(file).lower().endswith(tuple(IMG_EXTENSIONS)):
|
||||
open_file_with_geeqie(file)
|
||||
else:
|
||||
open_path_with_default_app(file)
|
||||
|
||||
|
||||
selectionTime = True
|
||||
while selectionTime:
|
||||
print("Options: [u]pload, [n]ext, [d]elete, [f]older, [q]uit")
|
||||
choice = getch().lower()
|
||||
|
||||
# Skip escape sequences (arrow keys)
|
||||
if choice.encode("utf-8") == b'\x1b':
|
||||
_ = getch() # Skip the next two characters
|
||||
_ = getch()
|
||||
continue
|
||||
|
||||
#print(choice.encode("utf-8")) # For debugging purposes
|
||||
|
||||
if choice == "u":
|
||||
print("\nUploading...")
|
||||
upload_file(file)
|
||||
print(f"\nSuccessfully uploaded {file}")
|
||||
print("Upload complete.")
|
||||
#selectionTime = False
|
||||
elif choice == "d":
|
||||
print("\nDeleting...")
|
||||
os.remove(file)
|
||||
usable_files.remove(file)
|
||||
selectionTime = False
|
||||
elif choice == "n":
|
||||
print("\nNext file...")
|
||||
selectionTime = False
|
||||
usable_files.remove(file)
|
||||
elif choice == "f":
|
||||
open_path_in_dolphin(file)
|
||||
elif choice == "q":
|
||||
print("\nQuitting...")
|
||||
pickFile = False
|
||||
break
|
||||
elif choice == "m":
|
||||
print("\nMoving to meme folder...")
|
||||
move_file_to_meme_folder(file)
|
||||
usable_files.remove(file)
|
||||
selectionTime = False
|
||||
else:
|
||||
print(f"\n'{choice}' is an invalid option. Please choose again.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -67,7 +67,6 @@ def collect_files(
|
||||
collected_files.append(file_path)
|
||||
return collected_files
|
||||
|
||||
|
||||
def upload_file(file_path: pathlib.Path) -> None:
|
||||
"""
|
||||
Upload the specified file to a Discord webhook.
|
||||
@@ -75,14 +74,13 @@ def upload_file(file_path: pathlib.Path) -> None:
|
||||
:param file_path: The path of the file to be uploaded.
|
||||
"""
|
||||
if WEBHOOK_URL is None:
|
||||
print("Error: RANDOM_FILE_PICKER_WEBHOOK_URL environment variable is not set.")
|
||||
return
|
||||
raise ValueError("RANDOM_FILE_PICKER_WEBHOOK_URL environment variable is not set.")
|
||||
|
||||
filename = file_path.name
|
||||
with open(file_path, "rb") as image_file:
|
||||
with file_path.open("rb") as image_file:
|
||||
image_data = image_file.read()
|
||||
files = {"file": (filename, io.BytesIO(image_data))}
|
||||
response = requests.post(WEBHOOK_URL, files=files, data={})
|
||||
response = requests.post(WEBHOOK_URL, files=files, data={}, timeout=60*5)
|
||||
response.raise_for_status() # Raise HTTPError for bad responses
|
||||
|
||||
def move_file_to_meme_folder(file_path: pathlib.Path) -> None:
|
||||
@@ -92,19 +90,16 @@ def move_file_to_meme_folder(file_path: pathlib.Path) -> None:
|
||||
:param file_path: The path of the file to be moved.
|
||||
"""
|
||||
if MEME_FOLDER is None:
|
||||
print("Error: RANDOM_FILE_PICKER_MEME_FOLDER environment variable is not set.")
|
||||
return
|
||||
raise ValueError("RANDOM_FILE_PICKER_MEME_FOLDER environment variable is not set.")
|
||||
meme_dir_path = pathlib.Path(MEME_FOLDER)
|
||||
|
||||
# Make sure the meme folder exists
|
||||
if not meme_dir_path.exists():
|
||||
print(f"Error: Meme folder '{meme_dir_path}' does not exist.")
|
||||
return
|
||||
raise RuntimeError(f"Meme folder '{meme_dir_path}' does not exist.")
|
||||
|
||||
# Make sure the file being moved is not already in the meme folder
|
||||
if file_path.parent.resolve() == meme_dir_path.resolve():
|
||||
print("File is already in the meme folder.")
|
||||
return
|
||||
raise RuntimeError("File is already in the meme folder.")
|
||||
|
||||
# Formulate the destination path and avoid overwriting existing files
|
||||
base_name = file_path.name
|
||||
@@ -115,9 +110,4 @@ def move_file_to_meme_folder(file_path: pathlib.Path) -> None:
|
||||
while destination_path.exists():
|
||||
destination_path = meme_dir_path / f"{name}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
ok = file_path.rename(destination_path)
|
||||
if not ok:
|
||||
print(f"Error moving {file_path} to {destination_path}")
|
||||
else:
|
||||
print(f"Moved {file_path} to {destination_path}")
|
||||
_ = file_path.rename(destination_path)
|
||||
|
||||
@@ -3,13 +3,14 @@ import pathlib
|
||||
import subprocess
|
||||
|
||||
|
||||
def open_path_with_default_app(file_path: pathlib.Path):
|
||||
def open_file_with_default_app(file_path: pathlib.Path) -> bool:
|
||||
"""
|
||||
Open the specified file path with the default application associated with its file type.
|
||||
|
||||
:param file_path: The path of the file to be opened.
|
||||
"""
|
||||
subprocess.run(["/usr/bin/xdg-open", str(file_path)])
|
||||
process = subprocess.Popen(["/usr/bin/xdg-open", str(file_path)]) # noqa: S603
|
||||
return process.wait() == 0 # xdg-open should instantly return, so we can check if it was successful
|
||||
|
||||
def open_file_with_geeqie(file_path: pathlib.Path):
|
||||
"""
|
||||
@@ -17,21 +18,19 @@ def open_file_with_geeqie(file_path: pathlib.Path):
|
||||
|
||||
:param file_path: The path of the file to be opened.
|
||||
"""
|
||||
print("Running subprocess to open file with Geeqie:", file_path)
|
||||
my_env = os.environ.copy()
|
||||
my_env["GQ_DISABLE_CLUTTER"] = "y"
|
||||
subprocess.Popen(
|
||||
_ = subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/geeqie",
|
||||
# "-t",
|
||||
# "--without-tools",
|
||||
"--file",
|
||||
str(file_path),
|
||||
],
|
||||
env=my_env,
|
||||
)
|
||||
|
||||
def open_path_in_dolphin(file_path: pathlib.Path):
|
||||
def open_file_in_dolphin(file_path: pathlib.Path):
|
||||
"""
|
||||
Open the specified file path in the Dolphin file manager and select the file.
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
A script to randomly select files from a directory, open them, and allow the user to upload them to a Discord webhook, delete them, or skip to the next file.
|
||||
|
||||
You can specify a file size limit, whether to descend into subdirectories, and provide blacklist/whitelist patterns for file selection.
|
||||
|
||||
Here's an example usage:
|
||||
./rand.py -s 5 -d /path/to/directory --descend --blacklist "*.png" "*.mp4"
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import random
|
||||
|
||||
import typed_argparse as tap
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import BindingType
|
||||
from textual.widgets import Footer, Header, Label
|
||||
|
||||
from random_file_picker.core import collect_files, move_file_to_meme_folder, upload_file
|
||||
from random_file_picker.openers import open_file_in_dolphin, open_file_with_default_app, open_file_with_geeqie
|
||||
|
||||
IMG_EXTENSIONS = [
|
||||
".jpg", ".jpeg", ".webp", ".png", ".gif",
|
||||
]
|
||||
|
||||
class Args(tap.TypedArgs):
|
||||
file_size: int = tap.arg("-s", default=10, help="Maximum file size in MB")
|
||||
directory: str = tap.arg("-d", default=".", help="Directory to search for files")
|
||||
descend: bool = tap.arg("--descend", default=False, help="Whether to descend into subdirectories")
|
||||
use_geeqie: bool = tap.arg("--use-geeqie", default=False, help="Whether to use Geeqie as the image viewer")
|
||||
blacklist: list[str] | None = tap.arg("--blacklist", nargs="*", help="List of glob patterns to blacklist files")
|
||||
whitelist: list[str] | None = tap.arg("--whitelist", nargs="*", help="List of glob patterns to whitelist files")
|
||||
|
||||
class MenuApp(App[None]):
|
||||
# This defines the keys shown in the footer
|
||||
BINDINGS: list[BindingType] = [
|
||||
("left", "prev_file", "Previous File"),
|
||||
("right", "next_file", "Next File"),
|
||||
("r", "reopen_file", "Reopen"),
|
||||
("u", "upload", "Upload"),
|
||||
("d", "delete", "Delete"),
|
||||
("m", "move_to_meme", "Move Meme"),
|
||||
("f", "open_folder", "Open Folder"),
|
||||
("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: str = ".",
|
||||
file_limit: int = 10,
|
||||
recurse: bool = False,
|
||||
blacklist: list[str] | None = None,
|
||||
whitelist: list[str] | None = None,
|
||||
use_geeqie: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.directory: pathlib.Path = pathlib.Path(directory)
|
||||
self.file_limit: int = file_limit
|
||||
self.recurse: bool = recurse
|
||||
self.blacklist_patterns: list[str] = blacklist or []
|
||||
self.whitelist_patterns: list[str] = whitelist or []
|
||||
self.use_geeqie: bool = use_geeqie
|
||||
self.usable_files: list[pathlib.Path] = []
|
||||
self.current_index: int = 0
|
||||
self.current_file: pathlib.Path | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Label("Loading files...", id="status")
|
||||
yield Label("", id="current-file")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.gather_files()
|
||||
if self.usable_files:
|
||||
self.current_index = 0
|
||||
self.display_current_file()
|
||||
|
||||
def display_current_file(self) -> None:
|
||||
"""Display the current file based on current_index."""
|
||||
if not self.usable_files or self.current_index >= len(self.usable_files):
|
||||
self.query_one("#status", Label).update("❌ No files available")
|
||||
self.current_file = None
|
||||
return
|
||||
|
||||
self.current_file = self.usable_files[self.current_index]
|
||||
file_count_str = f"({self.current_index + 1}/{len(self.usable_files)})"
|
||||
self.query_one("#current-file", Label).update(f"📄 {self.current_file} {file_count_str}")
|
||||
self.open_file()
|
||||
|
||||
def gather_files(self) -> None:
|
||||
"""Collect files from the directory based on filters."""
|
||||
self.usable_files = []
|
||||
|
||||
if not self.directory.exists() or not self.directory.is_dir():
|
||||
self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found")
|
||||
return
|
||||
|
||||
self.usable_files = collect_files(
|
||||
search_path=self.directory,
|
||||
search_subdirectories=self.recurse,
|
||||
file_size_limit=self.file_limit,
|
||||
blacklist_patterns=self.blacklist_patterns,
|
||||
whitelist_patterns=self.whitelist_patterns,
|
||||
)
|
||||
|
||||
if not self.usable_files:
|
||||
self.query_one("#status", Label).update("❌ No usable files found")
|
||||
else:
|
||||
self.query_one("#status", Label).update(f"✅ Found {len(self.usable_files)} files")
|
||||
random.shuffle(self.usable_files)
|
||||
|
||||
def open_file(self) -> None:
|
||||
"""Open the current file - display images in terminal, play others externally."""
|
||||
if not self.current_file:
|
||||
return
|
||||
|
||||
self.query_one("#status", Label).update(f"📝 Open {self.current_file}")
|
||||
try:
|
||||
if self.use_geeqie and str(self.current_file).lower().endswith(tuple(IMG_EXTENSIONS)):
|
||||
open_file_with_geeqie(self.current_file)
|
||||
else:
|
||||
ok = open_file_with_default_app(self.current_file)
|
||||
if not ok:
|
||||
self.query_one("#status", Label).update("❌ Failed to open file with default app")
|
||||
except Exception as e:
|
||||
self.query_one("#status", Label).update(f"❌ Failed to open file: {str(e)}")
|
||||
|
||||
def send_file(self) -> None:
|
||||
"""Upload the current file to Discord webhook."""
|
||||
if not self.current_file:
|
||||
return
|
||||
|
||||
try:
|
||||
self.query_one("#status", Label).update(f"⏳ Uploading {self.current_file.name} to webhook...")
|
||||
upload_file(self.current_file)
|
||||
self.query_one("#status", Label).update(f"✅ Uploaded {self.current_file.name} to webhook")
|
||||
except Exception as e:
|
||||
self.query_one("#status", Label).update(f"❌ Upload failed: {str(e)}")
|
||||
|
||||
def delete_file(self) -> None:
|
||||
"""Delete the current file."""
|
||||
if not self.current_file or self.current_index >= len(self.usable_files):
|
||||
return
|
||||
|
||||
try:
|
||||
self.current_file.unlink()
|
||||
_ = self.usable_files.pop(self.current_index)
|
||||
self.query_one("#status", Label).update("✅ Deleted file")
|
||||
|
||||
# Adjust index if we deleted the last file
|
||||
if self.current_index >= len(self.usable_files) and self.usable_files:
|
||||
self.current_index = len(self.usable_files) - 1
|
||||
|
||||
if self.usable_files:
|
||||
self.display_current_file()
|
||||
else:
|
||||
self.query_one("#status", Label).update("❌ No more files")
|
||||
except Exception as e:
|
||||
self.query_one("#status", Label).update(f"❌ Delete failed: {str(e)}")
|
||||
|
||||
def move_to_meme_folder(self) -> None:
|
||||
"""Move the current file to the meme folder."""
|
||||
if not self.current_file or self.current_index >= len(self.usable_files):
|
||||
return
|
||||
|
||||
try:
|
||||
move_file_to_meme_folder(self.current_file)
|
||||
_ = self.usable_files.pop(self.current_index)
|
||||
self.query_one("#status", Label).update("✅ Moved to meme folder")
|
||||
|
||||
# Adjust index if we removed the last file
|
||||
if self.current_index >= len(self.usable_files) and self.usable_files:
|
||||
self.current_index = len(self.usable_files) - 1
|
||||
|
||||
if self.usable_files:
|
||||
self.display_current_file()
|
||||
else:
|
||||
self.query_one("#status", Label).update("❌ No more files")
|
||||
except Exception as e:
|
||||
self.query_one("#status", Label).update(f"❌ Move failed: {str(e)}")
|
||||
|
||||
def open_in_folder(self) -> None:
|
||||
"""Open the file's folder in file manager."""
|
||||
if not self.current_file:
|
||||
return
|
||||
|
||||
open_file_in_dolphin(self.current_file)
|
||||
self.query_one("#status", Label).update("📂 Opened file in folder")
|
||||
|
||||
def action_upload(self) -> None:
|
||||
self.send_file()
|
||||
|
||||
def action_delete(self) -> None:
|
||||
self.delete_file()
|
||||
|
||||
def action_move_to_meme(self) -> None:
|
||||
self.move_to_meme_folder()
|
||||
|
||||
def action_open_folder(self) -> None:
|
||||
self.open_in_folder()
|
||||
|
||||
def action_next_file(self) -> None:
|
||||
if not self.usable_files:
|
||||
return
|
||||
self.current_index = (self.current_index + 1) % len(self.usable_files)
|
||||
self.display_current_file()
|
||||
|
||||
def action_prev_file(self) -> None:
|
||||
if not self.usable_files:
|
||||
return
|
||||
self.current_index = (self.current_index - 1) % len(self.usable_files)
|
||||
self.display_current_file()
|
||||
|
||||
def action_reopen_file(self) -> None:
|
||||
if not self.current_file:
|
||||
return
|
||||
self.open_file()
|
||||
self.query_one("#status", Label).update("🔄 Reopened file")
|
||||
|
||||
|
||||
def main_with_tap(args: Args) -> None:
|
||||
app = MenuApp(
|
||||
directory=args.directory,
|
||||
file_limit=args.file_size,
|
||||
recurse=args.descend,
|
||||
blacklist=args.blacklist or [],
|
||||
whitelist=args.whitelist or [],
|
||||
use_geeqie=args.use_geeqie,
|
||||
)
|
||||
app.run()
|
||||
|
||||
def main() -> None:
|
||||
tap.Parser(Args).bind(main_with_tap).run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user