#! /usr/bin/env python3 """ Move files and directories to custom trash directories, taking the corresponding mount points into account. """ from pathlib import Path import argparse import logging import sys import os import json from datetime import datetime from subprocess import check_output DEFAULT_CONFIG = { 'trashes': [ '$HOME/trash/', ] } def parse_args(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('names', nargs='*') parser.add_argument('-d', '--dry-run', action='store_true') parser.add_argument('-l', '--list', action='store_true') return parser.parse_args() def lowest_mount(path: Path) -> Path: """Find lowest level mount point of given path""" while not path.is_mount() and path.parent != path: return lowest_mount(path.parent) return path def getconfig() -> dict: config_file = Path('~/.config/trash.json').expanduser() config = DEFAULT_CONFIG if config_file.is_file(): with open(config_file) as f: config.update(json.load(f)) return config def get_trashes(config): trashes: list[Path] = [] for trashdir in config['trashes']: trashdir = Path(os.path.expandvars(trashdir)).expanduser().resolve() trashes.append(trashdir) return trashes def write_meta(original_dir, trash_timestamp_dir, calc_size=False): if calc_size: size = check_output(['du', '-sh', original_dir]).split()[0].decode('utf8') else: size = '?' with open(trash_timestamp_dir / '.trash_meta', 'w') as f: f.writelines([l + '\n' for l in [str(original_dir), size]]) def read_meta(meta_file: Path) -> tuple: with open(meta_file) as f: try: trashed_dir, size = f.readlines() trashed_dir = trashed_dir.strip() size = size.strip() except ValueError: logging.info(f'Meta information at {meta_file} outdated or corrupt') trashed_dir = size = None return trashed_dir, size def trash(names, config, dry_run=False): for name in names: name = Path(os.path.expandvars(name)) if not (name.is_file() or name.is_dir()): logging.warning(f'{name} is not a file nor directory') continue name = name.absolute() trashed = False for trashdir in get_trashes(config): try: if lowest_mount(trashdir) == lowest_mount(name): timestamp_prefix = trashdir / datetime.now().isoformat( timespec='seconds').replace(':', '_') newname = timestamp_prefix / '/'.join(name.parts[1:]) newname.parent.mkdir(parents=True, exist_ok=True) logging.debug(f'Will move {name} to {newname}') if not dry_run: write_meta(name, timestamp_prefix) name.rename(newname) trashed = True break except ValueError: pass if not trashed: logging.warning(f'Unable to trash {name}') def extract_timestamp(trashed_path: Path) -> datetime: # length of the used ISO format is 19! date_string = trashed_path.parts[-1][-19:].replace('_', ':') logging.debug(f'Extracted {date_string} from {trashed_path}') return datetime.fromisoformat(date_string) def trashed_sort_key(trashed_path: Path) -> int: return extract_timestamp(trashed_path).timestamp() def list_trashed(config) -> str: """ Yield trashed entries for trashdirs of given config """ for trashdir in get_trashes(config): for entry in sorted(trashdir.iterdir(), key=trashed_sort_key): meta_file = entry / '.trash_meta' if meta_file.is_file(): trashed_dir, size = read_meta(meta_file) yield f'{trashed_dir or entry} ({extract_timestamp(entry)}, {size})' else: yield entry def main(): args = parse_args() logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) config = getconfig() logging.debug(config) if args.list: print(*list(list_trashed(config)), sep='\n') sys.exit() trash(args.names, config, dry_run=args.dry_run) if args.dry_run: logging.info('Dry run. Nothin happened. I guess.') if __name__ == '__main__': main()