feat: list files as color coded lines and hide unused table columns
This commit is contained in:
parent
98044782e5
commit
d2e43e7839
4 changed files with 92 additions and 73 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
125
orvanitag/cmd.py
125
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue