feat: list files as color coded lines and hide unused table columns

This commit is contained in:
randogoth 2026-10-25 10:14:11 +02:00
parent 98044782e5
commit d2e43e7839
4 changed files with 92 additions and 73 deletions

View file

@ -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 |

View file

@ -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)

View file

@ -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)

View file

@ -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