diff --git a/README.md b/README.md index 32b9079..918ffad 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,11 @@ $ otag remove song.mp3 ### Searching -`list` (`ls`, `l`) searches the current directory tree and prints the matching files with their tags. Without a query it lists every tagged file. +`list` (`ls`, `l`) searches the current directory tree and prints the matching files with their tags. Without a query it lists every tagged file. Each file is shown on one color coded line, like in the `.otag` file but with its full path; `-t` shows a table instead. ``` $ otag ls '#lofi' @drums +song.mp3 --- +album @drums #lofi #snare first take ``` A term with a prefix only matches that category; a term without a prefix matches any category and the note. Terms match partially (`lof` finds `#lofi`) and all terms have to match. @@ -58,6 +59,7 @@ A term with a prefix only matches that category; a term without a prefix matches | `-or`, `--either` | Match any term instead of all | | `--files` | Only show files | | `-d`, `--dirs` | Only show folders | +| `-t`, `--table` | Show a table, with columns only for the categories the results use | | `-p`, `--path PATH` | Search another directory tree | | `-n`, `--no-recursive` | Skip subdirectories (also for `clean` and `scan`) | | `-c`, `--cache` | Read the index from `.otag.cache.json`, which is rebuilt whenever an `.otag` file changed | diff --git a/orvanitag/cmd.py b/orvanitag/cmd.py index 61bd51b..f390044 100644 --- a/orvanitag/cmd.py +++ b/orvanitag/cmd.py @@ -6,8 +6,8 @@ from rich import box from rich.console import Console from rich.markup import escape from rich.table import Table -from orvanitag.orvani import OrvaniTag, CONTEXT_FILE, CACHE_FILE, CONTEXTS, walk -from orvanitag.entry import Entry +from orvanitag.orvani import OrvaniTag, CONTEXT_FILE, CACHE_FILE, SEPARATOR, walk +from orvanitag.entry import Entry, CONTEXTS, LISTS from orvanitag.errors import OrvaniError, NotFound, NoEntry, UnsupportedType, UnsupportedFile, PluginError # Checked in order, so the base class comes last @@ -20,6 +20,8 @@ ERROR_ICONS = { OrvaniError: ':thinking_face:', } +STYLES = {'projects': 'cyan', 'contexts': 'yellow', 'tags': 'magenta', 'note': 'green'} + def shown(found: list, filter=None) -> list: """The found paths that pass the --files or --dirs filter""" if filter == 'files': @@ -28,79 +30,57 @@ def shown(found: list, filter=None) -> list: return [file for file in found if Path(file).is_dir()] return found -def mktable(query: str, index: dict, filter=None, trunc_len: int=25): - """Creates a rich table from `otag ls` search result - - query (str): The user query - index (dict): The context index returned from `otag ls` - filter (bool): Show only 'files' or 'dirs' +def styled(entry: Entry) -> dict: + """The escaped markup of each category the entry uses, in .otag order""" + parts = {name: ' '.join(CONTEXTS[name] + value for value in getattr(entry, name)) for name in LISTS} + parts['note'] = entry.note + return {name: escape(text) for name, text in parts.items() if text} + +def mktable(paths: list, items: dict, console, trunc_len: int=25): + """Prints the found files as a rich table, with a column for each category any of them uses + + paths (list): The found paths to show + items (dict): The entries of the search index by path trunc_len (int): Max. filename length beyond which overflow method kicks in - + """ - console = Console() + rows = [(file, styled(Entry.from_dict(items[file]))) for file in paths] + used = [name for name in CONTEXTS if any(name in parts for _, parts in rows)] - if len(index['found']) > 0: # is there a search result? + table = Table(box=box.HORIZONTALS, show_lines=True) + table.add_column("Path", style="white", no_wrap=False, overflow='fold', max_width=40) + for name in used: + table.add_column(name.title(), style=STYLES[name], overflow='fold') - # Create table - table = Table(box=box.HORIZONTALS, show_lines=True) + for file, parts in rows: + item = Path(file) - # File Path Column Header - table.add_column("Path", style="white", no_wrap=False, overflow='fold', max_width=40) + # Our own overflow method for filenames longer than 25 characters (file…name.ext) + long_file_pre = math.floor(trunc_len/2-1) + long_file_post = len(item.name) - math.ceil(trunc_len/2-1) + name = (item.name[:long_file_pre] + '…' + item.name[long_file_post:]) if len(item.name) > trunc_len else item.name + path = (item.parent / name).as_posix() - # Create Column Headers based on Context Schema - ctx = list(CONTEXTS.keys()) - styles = ["cyan", "yellow", "magenta", "green"] - for i, c in enumerate(ctx): - table.add_column(ctx[i].title(), style=styles[i], overflow='fold') - - # Create Rows Based on Search Results - for file in shown(index['found'], filter): - item = Path(file) + # Cells are read as rich markup, so [brackets] in names and notes need escaping + table.add_row(escape(path), *[parts.get(name) for name in used]) + console.print(table) - # Our own overflow method for filenames longer than 25 characters (file…name.ext) - long_file_pre = math.floor(trunc_len/2-1) - long_file_post = len(item.name) - math.ceil(trunc_len/2-1) - name = (item.name[:long_file_pre] + '…' + item.name[long_file_post:]) if len(item.name) > trunc_len else item.name - path = (item.parent / name).as_posix() - - fc = index['index']['items'][file] - elements = [] - elements.append(path) - - # Fetch contextual data for each file to add to a table row - for c in ctx: - if not c in fc: - fc[c] = None - if fc[c]: - if CONTEXTS[c]: - prefix = CONTEXTS[c] - else: - prefix = '' - if not type(fc[c]) == str: # Add respective context prefix - elements.append(' '.join([prefix + tag for tag in fc[c]])) - else: # Except for notes - elements.append(fc[c]) - else: - elements.append(None) - - # Cells are read as rich markup, so [brackets] in names and notes need escaping - table.add_row(*[escape(element) if element else element for element in elements]) - console.print(table) - else: - console.print(f":thinking_face: [red]No results found for [white]{query}") +def print_compact(paths: list, items: dict, console): + """Prints each found file as its color coded .otag line, with the full path""" + for file in paths: + name = file + '/' if Path(file).is_dir() else file + tags = ' '.join(f'[{STYLES[category]}]{text}[/{STYLES[category]}]' for category, text in styled(Entry.from_dict(items[file])).items()) + # Shown as saved: no emoji codes or highlighting, and the terminal wraps long lines + console.print(f'{escape(name)}[dim]{SEPARATOR}[/dim]{tags}', soft_wrap=True, emoji=False, highlight=False) def nice_context(entry: Entry): """ Rich colors for displaying an entry """ - string = '' - for name, color in [('projects', 'cyan'), ('contexts', 'yellow'), ('tags', 'magenta')]: - values = getattr(entry, name) - if values: - string += f'[{color}]' + escape(' '.join(CONTEXTS[name] + value for value in values)) + f'[/{color}] ' - if entry.note: - string += f"[green]'{escape(entry.note)}'[/green] " - return string + parts = styled(entry) + if 'note' in parts: + parts['note'] = f"'{parts['note']}'" + return ''.join(f'[{STYLES[name]}]{text}[/{STYLES[name]}] ' for name, text in parts.items()) def run_add(orvani, args, console): entry = orvani.add(args.file, *args.tags) @@ -118,13 +98,17 @@ def run_remove(orvani, args, console): 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) - filter = 'files' if args.files else 'dirs' if args.dirs else None - if sys.stdout.isatty(): - mktable(' '.join(args.terms), result, filter) - return - # Piped output is one bare path per line; plain print, since rich wraps long lines off a terminal - for file in shown(result['found'], filter): - print(file) + paths = shown(result['found'], 'files' if args.files else 'dirs' if args.dirs else None) + if not sys.stdout.isatty(): + # Piped output is one bare path per line; plain print, since rich wraps long lines off a terminal + for file in paths: + print(file) + elif not paths: + console.print(f":thinking_face: [red]No results found for [white]{escape(' '.join(args.terms))}") + elif args.table: + mktable(paths, result['index']['items'], console) + else: + print_compact(paths, result['index']['items'], console) def run_copy(orvani, args, console): @@ -228,6 +212,7 @@ def parser() -> ArgumentParser: 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('-t', '--table', action='store_true', help='show a table instead of one line per file') 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) diff --git a/tests/test_cmd.py b/tests/test_cmd.py index 1b24104..84a8a15 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -11,7 +11,10 @@ from orvanitag.cmd import main def otag(tmp_path, monkeypatch, capsys): monkeypatch.chdir(tmp_path) - def run(*argv): + def run(*argv, tty=False): + if tty: # capsys swaps sys.stdout in only once the test runs, so it is patched here + monkeypatch.setattr(sys.stdout, 'isatty', lambda: True) + monkeypatch.setenv('TERM', 'dumb') # no ANSI styles monkeypatch.setattr(sys, 'argv', ['otag', *argv]) main() return capsys.readouterr().out @@ -117,3 +120,32 @@ def test_piped_list_prints_bare_paths(otag, tmp_path): assert otag('ls', '--files') == 'sub/a long name that a table would wrap.mp3\n' assert otag('ls', '-d') == 'sub\n' assert otag('ls', '#none') == '' + + +def test_list_prints_otag_lines(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + (tmp_path / 'sub').mkdir() + otag('a', 'a.mp3', '#lofi', '+album', 'first', ':take:', '2') + otag('a', 'sub', '@archive') + assert otag('ls', tty=True) == 'a.mp3 --- +album #lofi first :take: 2\nsub/ --- @archive\n' + + +def test_table_shows_only_used_categories(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + otag('a', 'a.mp3', '#lofi') + out = otag('ls', '-t', tty=True) + assert 'Tags' in out and '#lofi' in out + assert not any(header in out for header in ['Projects', 'Contexts', 'Note']) + + +def test_piped_table_prints_bare_paths(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + otag('a', 'a.mp3', '#lofi') + assert otag('ls', '-t') == 'a.mp3\n' + + +def test_list_without_results(otag, tmp_path): + (tmp_path / 'a.mp3').touch() + otag('a', 'a.mp3', '#lofi') + assert 'No results found for #none' in otag('ls', '#none', tty=True) + assert 'No results found' in otag('ls', '-d', tty=True) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index bb3db6c..ec98750 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -175,7 +175,7 @@ def test_cmd_user_errors_exit_with_1(cwd, monkeypatch, capsys): def test_cmd_list_shows_square_brackets(cwd, monkeypatch, capsys): (cwd / 'a [demo].mp3').touch() OrvaniTag().add('a [demo].mp3', '#lofi', 'note', '[with]', 'brackets') - monkeypatch.setattr(sys.stdout, 'isatty', lambda: True) # the table, not piped output + monkeypatch.setattr(sys.stdout, 'isatty', lambda: True) # terminal output, not piped paths monkeypatch.setattr(sys, 'argv', ['otag', 'ls', '#lofi']) main() out = capsys.readouterr().out