diff --git a/README.md b/README.md index b970695..53ff0d8 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ Requires Python 3.11 or newer. The core only needs rich. Reading and writing emb ## Command Line Tool ``` -$ otag [arguments ...] [flags] +$ otag [flags] [arguments ...] ``` -Every command has a short alias, listed in brackets. `otag -h` prints an overview. Words starting with `+`, `@` or `#` become projects, contexts and tags; all other words form the note. Quote hashtags in the shell (`'#lofi'`), because an unquoted `#` starts a comment. +Every command has a short alias, listed in brackets. `otag -h` prints an overview, `otag -h` the arguments and flags of one command; flags go after the command. Words starting with `+`, `@` or `#` become projects, contexts and tags; all other words form the note. Quote hashtags in the shell (`'#lofi'`), because an unquoted `#` starts a comment. ### Tagging @@ -74,7 +74,7 @@ A term with a prefix only matches that category; a term without a prefix matches ### Copying, Moving and Deleting -`copy` (`cp`) and `move` (`mv`) transfer the tags of a file to another path. By default only the tags are transferred; `-f` (`--file-operation`) also copies or moves the file itself. With `-f`, an existing folder as destination keeps the file name. Without `-f`, a folder as destination gets the tags itself. +`copy` (`cp`) and `move` (`mv`) transfer the tags of a file to another path. By default only the tags are transferred; `-f` (`--with-file`) also copies or moves the file itself. With `-f`, an existing folder as destination keeps the file name. Without `-f`, a folder as destination gets the tags itself. ``` $ otag cp song.mp3 song-copy.mp3 diff --git a/orvanitag/cmd.py b/orvanitag/cmd.py index 7534cfc..4c3dbd8 100644 --- a/orvanitag/cmd.py +++ b/orvanitag/cmd.py @@ -1,6 +1,6 @@ import sys import math -from argparse import ArgumentParser as argp, SUPPRESS, RawTextHelpFormatter as raw +from argparse import ArgumentParser from pathlib import Path from rich import box from rich.console import Console @@ -98,150 +98,160 @@ def nice_context(entry: Entry): string += f"[green]'{escape(entry.note)}'[/green] " return string +def run_add(orvani, args, console): + entry = orvani.add(args.file, *args.tags) + console.print(f':label: Entry {nice_context(entry)}successfully added to file [strong]{escape(args.file)}[/strong].') + + +def run_remove(orvani, args, console): + entry = orvani.remove(args.file, *args.tags) + if entry is None: + console.print(f':wastebasket: All entries successfully removed from file [strong]{escape(args.file)}[/strong].') + else: + console.print(f':wastebasket: Entry {nice_context(entry)}successfully removed from file [strong]{escape(args.file)}[/strong].') + + +def run_list(orvani, args, console): + result = orvani.search(*args.terms, path=args.path, exact=args.exact, either=args.either, + recursive=args.recursive, cache=args.cache, delete_cache=args.delete_cache) + mktable(' '.join(args.terms), result, 'files' if args.files else 'dirs' if args.dirs else None) + + +def run_copy(orvani, args, console): + entry = orvani.copy(args.source, args.dest, with_file=args.with_file) + console.print(f':dancers: File [strong]{escape(args.source)}[/strong] with entry {nice_context(entry)}successfully copied to [strong]{escape(args.dest)}[/strong]') + + +def run_move(orvani, args, console): + entry = orvani.move(args.source, args.dest, with_file=args.with_file) + console.print(f':dancers: File [strong]{escape(args.source)}[/strong] with entry {nice_context(entry)}successfully moved to [strong]{escape(args.dest)}[/strong]') + + +def run_delete(orvani, args, console): + context_file = orvani.delete(args.file, with_file=args.with_file) + console.print(f':wastebasket: Entry for [strong]{escape(args.file)}[/strong] successfully removed.') + if args.with_file: + console.print(f':wastebasket: File [strong]{escape(args.file)}[/strong] successfully deleted.') + if context_file: + console.print(f':wastebasket: File [strong]{escape(str(context_file))}[/strong] automatically deleted.') + + +def run_clean(orvani, args, console): + count = orvani.clean(args.path, args.recursive) + sub = ' and subdirectories' if args.recursive else '' + console.print(f':broom: {count} entries cleaned from [strong]{escape(args.path)}[/strong]{sub}') + + +def run_purge(orvani, args, console): + path = Path(args.path) + for folder, files in walk(path.parent if path.is_file() else path): + for name in (CONTEXT_FILE, CACHE_FILE): + if name in files: + (folder / name).unlink(missing_ok=True) + console.print(f":wastebasket: File [strong]{escape(str(folder / name))}[/strong] successfully purged.") + + +def run_write(orvani, args, console): + entry = orvani.write_metadata(args.file, args.plugin) + console.print(f':floppy_disk: Metadata {nice_context(entry)}successfully written to file [strong]{escape(args.file)}[/strong].') + + +def run_export(orvani, args, console): + filename = orvani.export(args.file) + console.print(f':floppy_disk: All entries successfully written to file [strong]{escape(filename)}[/strong].') + + +def run_import(orvani, args, console): + count = orvani.import_file(args.file) + console.print(f':inbox_tray: [strong]{count}[/strong] entries successfully imported from file [strong]{escape(args.file)}[/strong].') + + +def run_scan(orvani, args, console): + orvani.scan(args.path, args.recursive) + console.print(f':mag: Finished scanning subdirectories.') + + +def parser() -> ArgumentParser: + """The otag command line: one subcommand per action, each with its own arguments and flags""" + p = ArgumentParser(prog='otag', description='OrvaniTagger: todo.txt style projects, contexts, tags and notes for files and folders.') + commands = p.add_subparsers(title='commands', metavar='COMMAND') + + def command(name, aliases, help, run): + sub = commands.add_parser(name, aliases=aliases, help=help, description=help) + sub.set_defaults(run=run) + return sub + + def no_recursive(sub): + sub.add_argument('-n', '--no-recursive', dest='recursive', action='store_false', help='skip subdirectories') + + def with_file(sub, help): + sub.add_argument('-f', '--with-file', action='store_true', help=help) + + tags = '+project, @context, #tag or note word' + + sub = command('add', ['a'], 'add tags and a note to an existing file or folder', run_add) + sub.add_argument('file') + sub.add_argument('tags', nargs='+', metavar='TAG', help=tags) + + sub = command('remove', ['r'], 'remove tags from a file, or its whole entry without any', run_remove) + sub.add_argument('file') + sub.add_argument('tags', nargs='*', metavar='TAG', help=tags) + + sub = command('list', ['ls', 'l'], 'list the tagged files matching all terms, or every tagged file', run_list) + sub.add_argument('terms', nargs='*', metavar='TERM', help='prefixed terms search their category, others all categories and notes') + sub.add_argument('-p', '--path', default='.', help='search another directory tree') + sub.add_argument('-e', '--exact', action='store_true', help='match whole terms only') + sub.add_argument('-or', '--either', action='store_true', help='match any term instead of all') + sub.add_argument('--files', action='store_true', help='only show files') + sub.add_argument('-d', '--dirs', action='store_true', help='only show folders') + sub.add_argument('-c', '--cache', action='store_true', help=f'read the index from {CACHE_FILE}, rebuilt when tags changed') + sub.add_argument('-dc', '--delete-cache', action='store_true', help=f'delete {CACHE_FILE} before searching') + no_recursive(sub) + + for name, aliases, verb, run in [('copy', ['cp'], 'copy', run_copy), ('move', ['mv'], 'move', run_move)]: + sub = command(name, aliases, f'{verb} the tags of a file to another path', run) + sub.add_argument('source') + sub.add_argument('dest') + with_file(sub, f'also {verb} the file itself') + + sub = command('del', ['rm'], 'remove the entry of a file', run_delete) + sub.add_argument('file') + with_file(sub, 'also delete the file itself') + + sub = command('clean', ['c'], 'remove entries of files that no longer exist', run_clean) + sub.add_argument('path', nargs='?', default='.') + no_recursive(sub) + + sub = command('purge', ['p'], f'delete all {CONTEXT_FILE} and {CACHE_FILE} files in a directory tree', run_purge) + sub.add_argument('path', nargs='?', default='.') + + sub = command('scan', ['s'], 'add tags embedded in files to their entries', run_scan) + sub.add_argument('path', nargs='?', default='.') + no_recursive(sub) + + sub = command('write', ['w'], 'write the entry of a file into the file itself', run_write) + sub.add_argument('plugin', help='comment, frontmatter, id3, office or pdf') + sub.add_argument('file') + + sub = command('export', ['e'], 'write all entries of a directory tree to a text file', run_export) + sub.add_argument('file', nargs='?', default='context.txt') + + sub = command('import', ['i'], 'add the missing tags from a text file written by export', run_import) + sub.add_argument('file', nargs='?', default='context.txt') + return p + + def main(): """otag CLI""" - - # Rich Console console = Console() - - # Detailed Help Text for the --help flag - command_help = """{add,a} FILE CONTEXT # add context to an existing file -{remove,r} FILE CONTEXT # remove context from an existing file -{list,ls,l} QUERY # list all files with queried context in current directory tree - -{copy,cp} SOURCE DEST # copy file and its context to another directory -{move,mv} SOURCE DEST # move file and its context to another directory -{del,rm} FILE # delete file and its context - -{clean,c} PATH # remove all context for nonexisting files -{purge,p} PATH # remove all context for all files in current directory tree - -{write,w} PLUGIN FILE # write all context of a file to the file as metadata -{import,i} FILE # import context for files from a text file -{export,e} FILE # export context for files to a text file -""" - # Command Line Argument Setup - p = argp(description="OrvaniTagger (otag)", formatter_class=raw) - p.add_argument("--no-recursive", "-n", dest="recursive", action="store_false", help="(list,clean,scan) Skip subdirectories") - p.add_argument("--file-operation", "-f", action="store_true", help="(copy,move,del) Do file operation, not just context") - p.add_argument("--path", "-p", action="store", type=str, help="(list,export) Use custom path", default='') - p.add_argument("--exact", "-e", action="store_true", help="(list) Match exact search term") - p.add_argument("--files", action="store_true", help="(list) Only show files, ignore folders") - p.add_argument("--dirs", "-d", action="store_true", help="(list) Only show directories, ignore files") - p.add_argument("--either", "-or", action="store_true", help="(list) Combine search terms by OR instead of AND") - p.add_argument("--cache", "-c", action="store_true", help="(list) Cache index search (useful with deep directory trees)") - p.add_argument("--delete-cache", "-dc", action="store_true", help="(list) Delete cache file") - p.add_argument("command", nargs="?", default=None, metavar='Command', - help=command_help, type=str, - choices=[ - 'add', 'a', - 'remove', 'r', - 'list', 'ls', 'l', - 'copy', 'cp', - 'move', 'mv', - 'del', 'rm', - 'clean', 'c', - 'purge', 'p', - 'write', 'w', - 'import', 'i', - 'export', 'e', - 'scan', 's' - ]) - p.add_argument("file", nargs="?", type=str, help=SUPPRESS) - p.add_argument("context", nargs="*", type=str, help=SUPPRESS) - p.print_usage = p.print_help + p = parser() args = p.parse_args() + if not hasattr(args, 'run'): # no command given + p.print_help() + return - # Parse Arguments and Run otag Methods try: - # Load Orvani - orvani = OrvaniTag() - - if args.command in ['add', 'a']: - if args.file and args.context: - context = orvani.add(args.file, *args.context) - console.print(f':label: Entry {nice_context(context)}successfully added to file [strong]{escape(str(args.file))}[/strong].') - - elif args.command in ['remove', 'r']: - if args.file: - context = orvani.remove(args.file, *args.context) - if context is None: - console.print(f':wastebasket: All entries successfully removed from file [strong]{escape(str(args.file))}[/strong].') - else: - console.print(f':wastebasket: Entry {nice_context(context)}successfully removed from file [strong]{escape(str(args.file))}[/strong].') - - elif args.command in ['list', 'ls', 'l']: - query = [args.file] + args.context - if query: - r = orvani.search(*query, path=args.path, exact=args.exact, either=args.either, recursive=args.recursive, cache=args.cache, delete_cache=args.delete_cache) - filter = None - if args.files: - filter = 'files' - elif args.dirs: - filter = 'dirs' - mktable(' '.join(sys.argv[1:]), r, filter) - - elif args.command in ['copy', 'cp', 'move', 'mv']: - if args.file and args.context: - copy = args.command in ['copy', 'cp'] - transfer = orvani.copy if copy else orvani.move - context = transfer(args.file, args.context[0], with_file=args.file_operation) - console.print(f':dancers: File [strong]{escape(str(args.file))}[/strong] with entry {nice_context(context)}successfully {"copied" if copy else "moved"} to [strong]{escape(str(args.context[0]))}[/strong]') - - elif args.command in ['del', 'rm']: - if args.file: - context_file = orvani.delete(args.file, with_file=args.file_operation) - status = f':wastebasket: Entry for [strong]{escape(str(args.file))}[/strong] successfully removed.' - if args.file_operation: - status += f'\n:wastebasket: File [strong]{escape(str(args.file))}[/strong] successfully deleted.' - if context_file: - status += f'\n:wastebasket: File [strong]{escape(str(context_file))}[/strong] automatically deleted.' - console.print(status) - - - elif args.command in ['purge', 'p']: - - def purge(path): - target = Path(path) - if target.is_file(): - target = Path(target.parent) - if target.is_dir(): - context_file = target / CONTEXT_FILE - if context_file.exists(): - context_file.unlink(missing_ok=True) - console.print(f":wastebasket: File [strong]{escape(str(context_file))}[/strong] successfully purged.") - cache_file = target / CACHE_FILE - if cache_file.exists(): - cache_file.unlink(missing_ok=True) - console.print(f":wastebasket: File [strong]{escape(str(cache_file))}[/strong] successfully purged.") - - if not args.file: - args.file = '.' - for folder, files in walk(args.file): - purge(folder) - - elif args.command in ['clean', 'c']: - count = orvani.clean(args.file or '', args.recursive) - sub = ' and subdirectories' if args.recursive else '' - console.print(f':broom: {count} entries cleaned from [strong]{escape(str(args.file or "."))}[/strong]{sub}') - - elif args.command in ['write', 'w']: - if args.file and args.context: - context = orvani.write_metadata(args.context[0], args.file) - console.print(f':floppy_disk: Metadata {nice_context(context)}successfully written to file [strong]{escape(str(args.context[0]))}[/strong].') - - elif args.command in ['export', 'e']: - filename = orvani.export(args.file) - console.print(f':floppy_disk: All entries successfully written to file [strong]{escape(str(filename))}[/strong].') - - elif args.command in ['import', 'i']: - count = orvani.import_file(args.file) - console.print(f':inbox_tray: [strong]{count}[/strong] entries successfully imported from file [strong]{escape(str(args.file or "context.txt"))}[/strong].') - - elif args.command in ['scan', 's']: - orvani.scan(args.file or '', args.recursive) - console.print(f':mag: Finished scanning subdirectories.') - + args.run(OrvaniTag(), args, console) except OrvaniError as e: icon = next(icon for kind, icon in ERROR_ICONS.items() if isinstance(e, kind)) console.print(f'{icon} {escape(str(e))}') diff --git a/tests/test_cmd.py b/tests/test_cmd.py new file mode 100644 index 0000000..b5d462a --- /dev/null +++ b/tests/test_cmd.py @@ -0,0 +1,59 @@ +import sys + +import pytest + +import orvanitag.orvani as orvani +from orvanitag.cmd import main + + +@pytest.fixture +def otag(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + def run(*argv): + monkeypatch.setattr(sys, 'argv', ['otag', *argv]) + main() + return capsys.readouterr().out + return run + + +def test_no_command_prints_help(otag): + out = otag() + for command in ['add', 'remove', 'list', 'copy', 'move', 'del', 'clean', 'purge', 'scan', 'write', 'export', 'import']: + assert command in out + + +def test_each_command_has_its_own_help(otag): + with pytest.raises(SystemExit) as exit: + otag('ls', '-h') + assert exit.value.code == 0 + + +def test_copy_move_and_delete_with_file(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + (tmp_path / 'sub').mkdir() + otag('a', 'a.mp3', '#lofi') + otag('cp', 'a.mp3', 'b.mp3') + otag('mv', '-f', 'a.mp3', 'sub') + assert (tmp_path / 'sub' / 'a.mp3').exists() and not (tmp_path / 'a.mp3').exists() + assert (tmp_path / orvani.CONTEXT_FILE).read_text() == 'b.mp3 --- #lofi\n' + + otag('rm', '--with-file', 'sub/a.mp3') + assert not (tmp_path / 'sub' / 'a.mp3').exists() + assert not (tmp_path / 'sub' / orvani.CONTEXT_FILE).exists() + + +def test_add_needs_tags(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + with pytest.raises(SystemExit) as exit: + otag('a', 'a.mp3') + assert exit.value.code == 2 + + +def test_purge_keeps_config(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + (tmp_path / orvani.CONFIG_FILE).write_text('plugins = []\n') + otag('a', 'a.mp3', '#lofi') + otag('p') + assert not (tmp_path / orvani.CONTEXT_FILE).exists() + assert (tmp_path / orvani.CONFIG_FILE).exists()