17 Commits
Author SHA1 Message Date
JustAnyone 346af4bdbb feat: add support for sort orders 2026-08-22 17:03:47 +03:00
JustAnyone b078184f2f feat: move file count to the front of the filename 2026-08-21 17:44:11 +03:00
JustAnyone d92ee5cb60 fix: tui name was MenuApp instead of File Picker 2026-08-21 17:10:15 +03:00
JustAnyone cf6e91017a feat: add support for jumping to specific item
This also cleans up some type checker issues
2026-08-21 17:08:29 +03:00
JustAnyone 454d4a9003 feat: add --no-random to forego the file order randomization
Kind of defeats the name of the project, huh
2026-08-21 17:04:06 +03:00
JustAnyone da7ae925f2 refactor: stop blocking UI when collecting files 2026-08-10 17:44:25 +03:00
JustAnyone da13b7b38b fix: allow moving files across devices 2026-07-20 23:33:53 +03:00
JustAnyone 8f196ab617 fix: let geeqie open JXL files 2026-07-15 16:04:13 +03:00
JustAnyone 42b9ee5ab0 feat: recognize jxl, avif, m4v and mkv formats 2026-07-15 15:58:08 +03:00
JustAnyone a5c317f083 feat: trash files by default 2026-07-15 15:01:37 +03:00
JustAnyone c449a2dffd fix: prevent operations with files that no longer exist 2026-07-15 14:54:38 +03:00
JustAnyone 37e8014af0 style: add missing override annotation 2026-07-15 14:51:45 +03:00
JustAnyone 6a9ffbf96a feat: validate whether geeqie is installed 2026-07-15 14:49:45 +03:00
JustAnyone a80aa34c62 refactor: add -r and --recursive as aliases to --descend 2026-07-15 14:45:31 +03:00
JustAnyone 290e79b7b1 refactor: rename RANDOM_FILE_PICKER_MEME_FOLDER to RANDOM_FILE_PICKER_MOVE_FOLDER 2026-07-15 14:42:14 +03:00
JustAnyone 5667833907 refactor: provide a proper TUI interface with textual 2026-06-29 16:48:46 +03:00
JustAnyone 3ab6b837f2 feat: add support for opening images in geeqie 2026-06-29 15:47:28 +03:00
7 changed files with 782 additions and 224 deletions
+3 -1
View File
@@ -8,4 +8,6 @@
!uv.lock
!random_file_picker/
!random_file_picker/__init__.py
!random_file_picker/cli.py
!random_file_picker/core.py
!random_file_picker/openers.py
!random_file_picker/tui.py
+4 -1
View File
@@ -6,6 +6,9 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32.5",
"send2trash>=2.1.0",
"textual>=8.2.7",
"typed-argparse>=0.3.1",
]
[build-system]
@@ -13,7 +16,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project.scripts]
pick-random-file = "random_file_picker.cli:main"
pick-random-file = "random_file_picker.tui:main"
[tool.uv.extra-build-dependencies]
random-file-picker = ["setuptools"]
-221
View File
@@ -1,221 +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 io
import os
import random
import requests
import subprocess
import argparse
import sys
import tty
import termios
import fnmatch
MEME_FOLDER = os.environ.get("RANDOM_FILE_PICKER_MEME_FOLDER", None)
WEBHOOK_URL = os.environ.get("RANDOM_FILE_PICKER_WEBHOOK_URL", None)
ALLOWED_EXTENSIONS = [
".jpg",
".jpeg",
".webp",
".png",
".gif",
".mp4",
".mov",
".webm",
]
IMG_EXTENSIONS = [
".jpg",
".jpeg",
".webp",
".png",
".gif",
]
def send_file(file_path: str):
if WEBHOOK_URL is None:
print("Error: RANDOM_FILE_PICKER_WEBHOOK_URL environment variable is not set.")
return
filename = os.path.basename(file_path)
with open(file_path, "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.raise_for_status() # Raise HTTPError for bad responses
print(f"\nSuccessfully uploaded {filename}")
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 open_with_gwenview(maybe_process: subprocess.Popen[bytes] | None, file_path: str) -> subprocess.Popen[bytes]:
if maybe_process is not None:
maybe_process.terminate()
maybe_process.wait()
process = subprocess.Popen(["gwenview", file_path])
return process
def open_in_folder(file_path: str):
subprocess.Popen(["dolphin", "--select", file_path])
#subprocess.run(["dolphin", "--select", file_path])
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('--direct', action='store_true', help='Whether to open files directly without xdg-open')
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
#open_directly: bool = args.direct
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 []
print(f"File size limit set to {file_limit} MB")
#print(f"Open files directly: {open_directly}")
if not os.path.exists(directory) or not os.path.isdir(directory):
print(f"Directory '{directory}' does not exist or is not a directory.")
return
search_path = os.path.abspath(directory)
# Collect files from the current directory
usable_files: list[str] = []
for directory, _, files in os.walk(search_path):
if directory != search_path and not descend:
continue
for f in files:
file_path = os.path.join(directory, f)
# Check against whitelist patterns
if whitelist_patterns and not any(fnmatch.fnmatch(file_path, pattern) for pattern in whitelist_patterns):
continue
# Check against blacklist patterns
if any(fnmatch.fnmatch(file_path, pattern) for pattern in blacklist_patterns):
continue
if not any(f.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS):
continue
stat_res = os.stat(file_path)
if stat_res.st_size < 1024 * 1024 * file_limit:
usable_files.append(file_path)
if not usable_files:
print("No usable files found.")
return
pickFile = True
gwenview_process = None
while pickFile:
file = random.choice(usable_files)
print(f"Random file: {file}")
if file.lower().endswith(tuple(IMG_EXTENSIONS)):
gwenview_process = open_with_gwenview(gwenview_process, file)
else:
subprocess.run(["xdg-open", 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...")
send_file(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_in_folder(file)
elif choice == "q":
print("\nQuitting...")
pickFile = False
break
elif choice == "m":
print("\nMoving to meme folder...")
if MEME_FOLDER is None:
print("Error: RANDOM_FILE_PICKER_MEME_FOLDER environment variable is not set.")
continue
# First, make sure we're not in the meme folder already
if os.path.abspath(os.path.dirname(file)) == os.path.abspath(MEME_FOLDER):
print("File is already in the meme folder.")
continue
# Also, ensure the meme folder exists
if not os.path.exists(MEME_FOLDER):
print(f"Meme folder '{MEME_FOLDER}' does not exist.")
continue
base_name = os.path.basename(file)
new_path = os.path.join(MEME_FOLDER, base_name)
# Avoid overwriting existing files
if os.path.exists(new_path):
name, ext = os.path.splitext(base_name)
counter = 1
while os.path.exists(new_path):
new_path = os.path.join(MEME_FOLDER, f"{name}_{counter}{ext}")
counter += 1
os.rename(file, new_path)
usable_files.remove(file)
selectionTime = False
else:
print(f"\n'{choice}' is an invalid option. Please choose again.")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
import fnmatch
import io
import os
import pathlib
import shutil
import requests
import send2trash
WEBHOOK_URL = os.environ.get("RANDOM_FILE_PICKER_WEBHOOK_URL", None)
MOVE_FOLDER = os.environ.get("RANDOM_FILE_PICKER_MOVE_FOLDER", None)
IMG_EXTENSIONS = [
".png",
".jpg", ".jpeg",
".webp",
".avif",
".jxl",
# kind-of, can be opened with image viewer
".gif",
]
SUPPORTED_EXTENSIONS = [
*IMG_EXTENSIONS,
".mp4",
".m4v",
".mov",
".mkv",
".webm",
]
def collect_files(
*,
search_path: pathlib.Path,
search_subdirectories: bool,
file_size_limit: int,
blacklist_patterns: list[str] | None = None,
whitelist_patterns: list[str] | None = None,
) -> list[pathlib.Path]:
"""
Collect files from the specified search_path, applying filters based on file size, blacklist patterns, and whitelist patterns.
:param search_path: The directory path to search for files.
:param search_subdirectories: If True, search in subdirectories as well.
:param file_size_limit: The maximum file size (in MB) for files to be included in the results.
:param blacklist_patterns: A list of patterns to exclude files from the results. If None, no files will be excluded based on patterns.
:param whitelist_patterns: A list of patterns to include files in the results. If None, all files will be included regardless of patterns.
:return: A list of file paths that meet the specified criteria.
"""
collected_files: list[pathlib.Path] = []
# Set default values for blacklist and whitelist patterns if they are None
blacklist_patterns = blacklist_patterns or []
whitelist_patterns = whitelist_patterns or []
search_path = search_path.resolve()
for directory, _, files in search_path.walk():
if directory != search_path and not search_subdirectories:
continue
for f in files:
file_path = directory / f
# Check against whitelist patterns
if whitelist_patterns and not any(fnmatch.fnmatch(str(file_path), pattern) for pattern in whitelist_patterns):
continue
# Check against blacklist patterns
if any(fnmatch.fnmatch(str(file_path), pattern) for pattern in blacklist_patterns):
continue
# Make sure the file has a supported extension
if not any(f.lower().endswith(ext) for ext in SUPPORTED_EXTENSIONS):
continue
stat_res = file_path.stat()
if stat_res.st_size < 1024 * 1024 * file_size_limit:
collected_files.append(file_path)
return collected_files
def upload_file(file_path: pathlib.Path) -> None:
"""
Upload the specified file to a Discord webhook.
:param file_path: The path of the file to be uploaded.
"""
if WEBHOOK_URL is None:
raise ValueError("RANDOM_FILE_PICKER_WEBHOOK_URL environment variable is not set.")
filename = file_path.name
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={}, timeout=60*5)
response.raise_for_status() # Raise HTTPError for bad responses
def move_file_to_move_folder(file_path: pathlib.Path) -> None:
"""
Move the specified file to the move folder defined by the RANDOM_FILE_PICKER_MOVE_FOLDER environment variable.
:param file_path: The path of the file to be moved.
"""
if MOVE_FOLDER is None:
raise ValueError("RANDOM_FILE_PICKER_MOVE_FOLDER environment variable is not set.")
move_dir_path = pathlib.Path(MOVE_FOLDER)
# Make sure the move folder exists
if not move_dir_path.exists():
raise RuntimeError(f"Move folder '{move_dir_path}' does not exist.")
# Make sure the file being moved is not already in the move folder
if file_path.parent.resolve() == move_dir_path.resolve():
raise RuntimeError("File is already in the move folder.")
# Formulate the destination path and avoid overwriting existing files
base_name = file_path.name
destination_path = move_dir_path / base_name
if destination_path.exists():
name, ext = destination_path.stem, destination_path.suffix
counter = 1
while destination_path.exists():
destination_path = move_dir_path / f"{name}_{counter}{ext}"
counter += 1
_ = shutil.move(file_path, destination_path)
def trash_file(file_path: pathlib.Path) -> None:
"""
Put the specified file into the system trash using the send2trash library.
:param file_path: The path of the file to be trashed.
"""
send2trash.send2trash(file_path)
+48
View File
@@ -0,0 +1,48 @@
import os
import pathlib
import shutil
import subprocess
def is_geeqie_available() -> bool:
"""
Check if the Geeqie image viewer is available on the system.
:return: True if Geeqie is available, False otherwise.
"""
return shutil.which("geeqie") is not None
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.
"""
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):
"""
Open the specified file path with the Geeqie image viewer.
:param file_path: The path of the file to be opened.
"""
my_env = os.environ.copy()
my_env["GQ_DISABLE_CLUTTER"] = "y"
_ = subprocess.Popen(
[
"/usr/bin/geeqie",
# "-t",
"--file",
str(file_path),
],
env=my_env,
)
def open_file_in_dolphin(file_path: pathlib.Path):
"""
Open the specified file path in the Dolphin file manager and select the file.
:param file_path: The path of the file to be opened in Dolphin.
"""
subprocess.Popen(["/usr/bin/dolphin", "--select", str(file_path)])
+445
View File
@@ -0,0 +1,445 @@
"""
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
from collections.abc import Callable
from typing import ClassVar, cast, final, override
import typed_argparse as tap
from textual import work
from textual.app import App, ComposeResult
from textual.binding import BindingType
from textual.containers import Grid
from textual.screen import ModalScreen
from textual.widgets import Footer, Header, Input, Label
from textual.worker import Worker, WorkerState
from random_file_picker.core import IMG_EXTENSIONS, collect_files, move_file_to_move_folder, trash_file, upload_file
from random_file_picker.openers import (
is_geeqie_available,
open_file_in_dolphin,
open_file_with_default_app,
open_file_with_geeqie,
)
GEEQIE_AVAILABLE = is_geeqie_available()
# Map valid sort keys to the function that extracts the comparison value
SORT_KEYS: dict[str, Callable[[pathlib.Path], float | int | str]] = {
"name": lambda p: p.name.lower(),
"date": lambda p: p.stat().st_mtime,
"size": lambda p: p.stat().st_size,
}
# Map valid order strings to their `reverse` boolean flag (True = descending)
SORT_ORDERS: dict[str, bool] = {
"a": False, "asc": False, "ascending": False,
"d": True, "desc": True, "descending": True,
}
def parse_and_sort_files(files: list[pathlib.Path], sort_args: list[str]) -> None:
"""Sort files in place and raise ValueError on invalid rules."""
rules: list[str] = []
for arg in sort_args:
rules.extend(r.strip() for r in arg.split(",") if r.strip())
for rule in reversed(rules):
parts = rule.split(":")
key_str = parts[0].strip().lower()
order_str = parts[1].strip().lower() if len(parts) > 1 else "a"
if key_str not in SORT_KEYS:
raise ValueError(f"Invalid sort key '{key_str}'. Valid options: {', '.join(SORT_KEYS.keys())}")
if order_str not in SORT_ORDERS:
raise ValueError(f"Invalid sort order '{order_str}'. Valid options: {', '.join(SORT_ORDERS.keys())}")
files.sort(key=SORT_KEYS[key_str], reverse=SORT_ORDERS[order_str])
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")
recursive: bool = tap.arg("-r", "--recursive", "--descend", default=False, help="Whether to recurse into subdirectories")
use_geeqie: bool = tap.arg("--use-geeqie", default=False, help="Whether to use Geeqie as the image viewer")
no_trash: bool = tap.arg("--no-trash", default=False, help="Delete files immediately without moving to trash")
no_random: bool = tap.arg("--no-random", default=False, help="Do not randomize the order of files")
sort: list[str] | None = tap.arg("--sort", nargs="*", help="Sort rules (e.g. 'date:d, name:a'). Keys: name, date, size. Orders: a, d.")
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")
@final
class JumpModal(ModalScreen[int | None]):
"""Modal screen to prompt the user for an item index to skip to."""
# css in the terminal before gta6
DEFAULT_CSS = """
JumpModal {
align: center middle;
}
#dialog {
grid-size: 1;
padding: 1 2;
width: 40;
height: 11;
border: thick $background 80%;
background: $surface;
}
"""
@override
def compose(self) -> ComposeResult:
yield Grid(
Label("Enter item index to jump to:"),
Input(placeholder="e.g. 5", id="index-input", type="integer"),
id="dialog",
)
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle the submission of the input field, dismissing the modal with the entered index."""
val = event.value.strip()
if val.isdigit():
_ = self.dismiss(int(val))
return
_ = self.dismiss(None)
@final
class MenuApp(App[None]):
TITLE = "File Picker"
# This defines the keys shown in the footer
BINDINGS: ClassVar[list[BindingType]] = [
("left", "prev_file", "Previous File"),
("right", "next_file", "Next File"),
("r", "reopen_file", "Reopen"),
("colon", "jump_to_item", "Jump to Item"),
("g", "jump_to_item", "Jump to Item"),
("u", "upload", "Upload"),
("d", "delete", "Delete"),
("m", "move_to_folder", "Move File"),
("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,
use_trash: bool = True,
no_random: bool = False,
sort: list[str] | None = None,
):
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.use_trash: bool = use_trash
self.no_random: bool = no_random
self.sort_rules: list[str] = sort or []
self.usable_files: list[pathlib.Path] = []
self.current_index: int = 0
self.current_file: pathlib.Path | None = None
self.is_loading: bool = False
@override
def compose(self) -> ComposeResult:
yield Header()
yield Label("Loading files...", id="status")
yield Label("", id="current-file")
yield Footer()
def on_mount(self) -> None:
"""On application mount, start gathering files in the background."""
self.start_gathering_files()
@override
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Disable every action except quit while files are being gathered."""
if self.is_loading and action != "quit":
return None
return True
def start_gathering_files(self) -> None:
"""Start file collection in a background worker so the UI stays responsive."""
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.is_loading = True
self.refresh_bindings()
self.query_one("#current-file", Label).update(f"🔍 Scanning '{self.directory}' for files, please wait...")
status = self.query_one("#status", Label)
status.update("")
status.loading = True
_ = self.gather_files_worker(
self.directory, self.file_limit, self.blacklist_patterns, self.whitelist_patterns,
recurse=self.recurse,
)
@work(thread=True, exclusive=True)
def gather_files_worker(
self,
directory: pathlib.Path,
file_limit: int,
blacklist_patterns: list[str],
whitelist_patterns: list[str],
*,
recurse: bool,
) -> list[pathlib.Path]:
"""Run file collection in a worker thread so a large/recursive scan doesn't block the UI."""
return collect_files(
search_path=directory,
search_subdirectories=recurse,
file_size_limit=file_limit,
blacklist_patterns=blacklist_patterns,
whitelist_patterns=whitelist_patterns,
)
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle the completion of the background file scan, updating the UI with results or errors."""
worker = cast("Worker[list[pathlib.Path]]", event.worker)
if worker.name != "gather_files_worker":
return
status = self.query_one("#status", Label)
self.is_loading = False
status.loading = False
self.refresh_bindings()
if event.state == WorkerState.SUCCESS:
files = worker.result or []
self.usable_files = list(files)
if self.sort_rules:
try:
parse_and_sort_files(self.usable_files, self.sort_rules)
except ValueError as e:
status.update(f"❌ Sorting error: {e}")
return
if not self.no_random:
random.shuffle(self.usable_files)
if not self.usable_files:
status.update("❌ No usable files found")
else:
status.update(f"✅ Found {len(self.usable_files)} files")
self.current_index = 0
self.display_current_file()
elif event.state == WorkerState.ERROR:
status.update(f"❌ Failed to scan directory: {worker.error}")
def assert_current_file_exists(self) -> bool:
"""Check if the current file exists and update the status label accordingly."""
if not self.current_file or not self.current_file.exists():
self.query_one("#status", Label).update(f"❌ File '{self.current_file}' not found")
return False
return True
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"📄 {file_count_str} {self.current_file}")
self.open_file()
def open_file(self) -> None:
"""Open the current file - display images in terminal, play others externally."""
if not self.current_file:
return
if not self.assert_current_file_exists():
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
if not self.assert_current_file_exists():
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 (move to trash if enabled)."""
if not self.current_file or self.current_index >= len(self.usable_files):
return
if not self.assert_current_file_exists():
return
try:
if self.use_trash:
trash_file(self.current_file)
else:
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_move_folder(self) -> None:
"""Move the current file to the move folder."""
if not self.current_file or self.current_index >= len(self.usable_files):
return
if not self.assert_current_file_exists():
return
try:
move_file_to_move_folder(self.current_file)
_ = self.usable_files.pop(self.current_index)
self.query_one("#status", Label).update("✅ Moved to move 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 current file's folder in the file manager."""
if not self.current_file:
return
if not self.assert_current_file_exists():
return
open_file_in_dolphin(self.current_file)
self.query_one("#status", Label).update("📂 Opened file in folder")
def action_upload(self) -> None:
"""Upload the current file to Discord webhook."""
self.send_file()
def action_delete(self) -> None:
"""Delete the current file (move to trash if enabled)."""
self.delete_file()
def action_move_to_folder(self) -> None:
"""Move the current file to the move folder."""
self.move_to_move_folder()
def action_open_folder(self) -> None:
"""Open the current file's folder in the file manager."""
self.open_in_folder()
def action_next_file(self) -> None:
"""Display the next file."""
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:
"""Display the previous file."""
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:
"""Reopen the current file."""
if not self.current_file:
return
self.open_file()
self.query_one("#status", Label).update("🔄 Reopened file")
def action_jump_to_item(self) -> None:
"""Prompt for item number and jump to it."""
if not self.usable_files:
return
def check_jump(target_1based: int | None) -> None:
if target_1based is None:
return
if 1 <= target_1based <= len(self.usable_files):
self.current_index = target_1based - 1
self.display_current_file()
else:
self.query_one("#status", Label).update(f"❌ Index out of range (1 - {len(self.usable_files)})")
_ = self.push_screen(JumpModal(), check_jump)
def main_with_tap(args: Args) -> None:
if args.use_geeqie and not GEEQIE_AVAILABLE:
print("❌ Geeqie is not available on this system. Please install it or run without --use-geeqie.")
return
# Validate sort arguments before launching the TUI
if args.sort:
try:
parse_and_sort_files([], args.sort)
except ValueError as e:
print(f"❌ Sorting error: {e}")
return
app = MenuApp(
directory=args.directory,
file_limit=args.file_size,
recurse=args.recursive,
blacklist=args.blacklist or [],
whitelist=args.whitelist or [],
use_geeqie=args.use_geeqie,
use_trash=not args.no_trash,
no_random=args.no_random,
sort=args.sort or [], # default filesystem order if no sort rules are provided
)
app.run()
def main() -> None:
tap.Parser(Args).bind(main_with_tap).run()
if __name__ == "__main__":
main()
Generated
+150 -1
View File
@@ -2,6 +2,10 @@ version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1W"
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -77,16 +81,92 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "random-file-picker"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "requests" },
{ name = "send2trash" },
{ name = "textual" },
{ name = "typed-argparse" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.32.5" }]
requires-dist = [
{ name = "requests", specifier = ">=2.32.5" },
{ name = "send2trash", specifier = ">=2.1.0" },
{ name = "textual", specifier = ">=8.2.7" },
{ name = "typed-argparse", specifier = ">=0.3.1" },
]
[[package]]
name = "requests"
@@ -103,6 +183,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "send2trash"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" },
]
[[package]]
name = "textual"
version = "8.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" },
]
[[package]]
name = "typed-argparse"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/74/2608ef98de41cd82743be47185048db93b4ff1bc59714666145de4376a1a/typed-argparse-0.3.1.tar.gz", hash = "sha256:3aac61caa50206e080d09a00c3fe552bc4e642739beaef89f5f8c1131b5d5afe", size = 18394, upload-time = "2023-09-25T20:40:20.337Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/86/2217b32ee0f421ebf57df7ff898638eedfe2288aa6710dba2bca0607b689/typed_argparse-0.3.1-py3-none-any.whl", hash = "sha256:1fbbc3c6adde19aa04edafebd8a7efccab4ddd403d03ec68172cc20b237bbaa7", size = 19686, upload-time = "2023-09-25T20:40:18.691Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"