Say the date and the time, and sort by both
The listing gave the time and not the date, which is fine for an evening's scanning and useless for a directory that has been filling for a fortnight: two recordings a week apart looked like neighbours. Every moment in the browser is now written YY-mm-dd hh:mm:ss am/pm -- date first so a column of them reads down in order, twelve-hour so it reads the way it would be said. Midnight is 12 am and noon is 12 pm, which is the pair a twelve-hour clock gets wrong when it is done by subtraction, so both are tested. The sort is named "date/time" rather than "time" and orders by the whole moment, with the filename breaking a tie so that the list does not shuffle itself between one reload and the next. --sort time still works; it is the sort of thing that lives in a shell alias. The date costs twelve columns, so the frequency column is now only as wide as the widest frequency in the list rather than always wide enough for 1090.000001 MHz. It is fixed for the whole list, not the screenful, because a column that resizes as the list scrolls under it makes the whole listing appear to twitch. --list is left in ISO and twenty-four hours: it is there to be piped into grep and sort, and those want the format that sorts as text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
3d7f76118e
commit
0f378c4d6c
7 changed files with 178 additions and 27 deletions
14
README.md
14
README.md
|
|
@ -1306,7 +1306,7 @@ or symbol rate where there is one, and the bands the frequency falls in.
|
|||
|
||||
```
|
||||
╭──────────────────────────────────────────────────────────── 4 of 126 ─╮
|
||||
│ 146.88 MHz NFM Sat 22 Aug 13:01:44 42.8s SNR 17.6 dB voice │
|
||||
│ 146.88 MHz NFM Sat 26-08-22 01:01:44 pm 42.8s SNR 17.6 dB voice │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
╭─ transcript ──────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
|
|
@ -1320,10 +1320,10 @@ or symbol rate where there is one, and the bands the frequency falls in.
|
|||
│ 2 m Amateur · 2 m FM Simplex · 2 m Repeater Outputs │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
╭─ recordings in /mnt/global/bandsaunter ───────────────────────────────╮
|
||||
│ 856.561096 MHz 13:10:25 fsk 4m00s Motorola SMARTNET / Smart… │
|
||||
│ 158.294200 MHz 13:05:15 nfm 20.1s Steven, I'm over to Colvi… │
|
||||
│ › 146.88 MHz 13:01:44 nfm 42.8s Alright, moving on. It is… │
|
||||
│ 146.88 MHz 13:00:44 nfm 35.2s Check out communication o… │
|
||||
│ 856.561096 MHz 26-08-22 01:10:25 pm fsk 4m00s Motorola SMARTNE… │
|
||||
│ 158.294200 MHz 26-08-22 01:05:15 pm nfm 20.1s Steven, I'm over… │
|
||||
│ › 146.88 MHz 26-08-22 01:01:44 pm nfm 42.8s Alright, moving … │
|
||||
│ 146.88 MHz 26-08-22 01:00:44 pm nfm 35.2s Check out commun… │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
↑↓ move ⏎ play space stop / search t read S I N file d delete m mask q quit
|
||||
```
|
||||
|
|
@ -1337,7 +1337,8 @@ or symbol rate where there is one, and the bands the frequency falls in.
|
|||
| `t` | read the whole transcript full screen, scrolling |
|
||||
| `/` | filter — by frequency, filename, identification, **or anything that was said** |
|
||||
| — | callsigns are found and looked up automatically; no key needed |
|
||||
| `s` | sort by time, frequency or length |
|
||||
| `s` | sort by date/time, frequency or length |
|
||||
| — | each line gives the date and time as `YY-mm-dd hh:mm:ss am/pm`, newest first |
|
||||
| `r` | re-read the directory, picking up what a running scan has written |
|
||||
| `o` | print the file's path and quit |
|
||||
| — | a picture is marked in the list, with the path of its PNG |
|
||||
|
|
@ -1501,6 +1502,7 @@ repeater"* is a question about content, not about filenames.
|
|||
```bash
|
||||
saunterbrowse --list | grep -i "mile marker" # or ask it from a script
|
||||
saunterbrowse --sort frequency # group by channel, not by time
|
||||
saunterbrowse --sort date/time # newest first: the default
|
||||
```
|
||||
|
||||
Playback is handed to whichever player is installed — `pw-play`, `paplay`,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ and transcribing speech.
|
|||
# Versions are the release date and a revision within that day, so
|
||||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||
# to two digits so versions sort as text.
|
||||
VERSION_DATE = "2026-08-29"
|
||||
VERSION_REVISION = 3
|
||||
VERSION_DATE = "2026-08-30"
|
||||
VERSION_REVISION = 1
|
||||
|
||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||
|
||||
|
|
|
|||
|
|
@ -614,7 +614,11 @@ class Keyboard:
|
|||
# The browser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SORTS = ("time", "frequency", "duration")
|
||||
SORTS = ("date/time", "frequency", "duration")
|
||||
|
||||
# What --sort used to be called. Kept working because it is the sort of thing
|
||||
# people put in a shell alias and never look at again.
|
||||
SORT_ALIASES = {"time": "date/time", "date": "date/time"}
|
||||
|
||||
# The three subdirectories a recording can be filed into, and the key that
|
||||
# does it. Upper case on purpose: j and k are under the same fingers, and a
|
||||
|
|
@ -630,6 +634,42 @@ FILING: tuple[tuple[str, str, str], ...] = (
|
|||
_FILING_KEYS = {key: name for key, name, _ in FILING}
|
||||
|
||||
|
||||
# How a moment is written everywhere in the browser: the date first so that a
|
||||
# column of them reads down in order, then the clock the way a person says it.
|
||||
# Zero-padded on purpose -- an unpadded hour puts a ragged edge down the middle
|
||||
# of the listing, and a column that does not line up is a column nobody reads.
|
||||
STAMP = "%y-%m-%d %I:%M:%S %p"
|
||||
STAMP_WIDTH = 20
|
||||
|
||||
|
||||
def _stamp(when: datetime | None) -> str:
|
||||
"""One capture's date and time, or an empty string when it has neither."""
|
||||
if when is None:
|
||||
return ""
|
||||
# Lower case because AM in capitals shouts, and the field beside it is a
|
||||
# frequency: the eye should land on the number, not on the meridiem.
|
||||
return when.strftime(STAMP).lower()
|
||||
|
||||
|
||||
# The widest frequency there is room for -- 1090.000001 MHz -- and the
|
||||
# narrowest worth reserving.
|
||||
FREQ_WIDTH = (9, 15)
|
||||
|
||||
|
||||
def _freq_width(captures) -> int:
|
||||
"""How wide the frequency column has to be for these recordings.
|
||||
|
||||
Fixed at the width of the widest frequency in the whole list rather than
|
||||
the widest on screen: a column that changes width as the list scrolls
|
||||
under it makes the whole listing appear to twitch. Anything given up
|
||||
here goes to the end of the line, which is where what was said is.
|
||||
"""
|
||||
widest = max((len(fmt_hz(c.frequency)) if c.frequency
|
||||
else min(FREQ_WIDTH[1], len(c.path.stem))
|
||||
for c in captures), default=0)
|
||||
return max(FREQ_WIDTH[0], min(FREQ_WIDTH[1], widest))
|
||||
|
||||
|
||||
def _dur(seconds: float) -> str:
|
||||
seconds = max(0.0, float(seconds))
|
||||
if seconds >= 3600:
|
||||
|
|
@ -665,7 +705,8 @@ class Browser:
|
|||
self.view: list[Capture] = []
|
||||
self.index = 0
|
||||
self.top = 0 # first row shown in the list
|
||||
self.sort = "time"
|
||||
self.sort = SORTS[0]
|
||||
self.freq_width = FREQ_WIDTH[0]
|
||||
self.query = ""
|
||||
self.searching = False
|
||||
self.confirm = "" # an action waiting to be agreed to
|
||||
|
|
@ -702,8 +743,14 @@ class Browser:
|
|||
elif self.sort == "duration":
|
||||
self.view.sort(key=lambda c: c.duration, reverse=True)
|
||||
else:
|
||||
self.view.sort(key=lambda c: c.started_at, reverse=True)
|
||||
# Newest first, and strictly by the whole moment -- year, month,
|
||||
# day, then hour, minute, second -- because started_at is one
|
||||
# number counting from the epoch rather than a formatted string.
|
||||
# The filename breaks a tie, which only happens when two signals
|
||||
# landed in the same second.
|
||||
self.view.sort(key=lambda c: (-c.started_at, c.path.name))
|
||||
self.index = max(0, min(self.index, len(self.view) - 1))
|
||||
self.freq_width = _freq_width(self.view)
|
||||
|
||||
@staticmethod
|
||||
def _matches(cap: Capture, q: str) -> bool:
|
||||
|
|
@ -1016,7 +1063,7 @@ class Browser:
|
|||
when = cap.when
|
||||
if when is not None:
|
||||
left.append(" ")
|
||||
left.append(when.strftime("%a %d %b %H:%M:%S"), style="white")
|
||||
left.append(f"{when:%a} {_stamp(when)}", style="white")
|
||||
left.append(" ")
|
||||
left.append(_dur(cap.duration), style="bright_black")
|
||||
snr = cap.meta.get("snr_db")
|
||||
|
|
@ -1230,8 +1277,8 @@ class Browser:
|
|||
t = Table(box=None, expand=True, pad_edge=False, show_edge=False,
|
||||
show_header=False)
|
||||
t.add_column(width=2) # cursor / playing marker
|
||||
t.add_column(width=15, justify="right") # frequency
|
||||
t.add_column(width=8) # time
|
||||
t.add_column(width=self.freq_width, justify="right") # frequency
|
||||
t.add_column(width=STAMP_WIDTH) # date and time
|
||||
t.add_column(width=5) # mode
|
||||
t.add_column(width=7, justify="right") # duration
|
||||
# One line per recording, always. A wrapped row would push the ones
|
||||
|
|
@ -1258,8 +1305,7 @@ class Browser:
|
|||
Text(fmt_hz(cap.frequency) if cap.frequency
|
||||
else cap.path.stem[:15],
|
||||
style=base + ("bold cyan" if here else "cyan")),
|
||||
Text(when.strftime("%H:%M:%S") if when else "",
|
||||
style=base + "bright_black"),
|
||||
Text(_stamp(when), style=base + "bright_black"),
|
||||
Text(cap.mode or "", style=base + "white"),
|
||||
Text(_dur(cap.duration), style=base + "bright_black"),
|
||||
Text(summary, style=base + (cat if not cap.transcript
|
||||
|
|
@ -1373,7 +1419,7 @@ class Browser:
|
|||
("t", "read the whole transcript, full screen"),
|
||||
("/", "filter by frequency, name, class or anything said"),
|
||||
("Esc", "clear the filter"),
|
||||
("s", "sort by time, frequency or length"),
|
||||
("s", "sort by date/time, frequency or length"),
|
||||
("r", "re-read the directory"),
|
||||
("o", "print the file's path and quit"),
|
||||
("", ""),
|
||||
|
|
@ -1434,7 +1480,7 @@ class Browser:
|
|||
title += f" · {fmt_hz(cap.frequency)}"
|
||||
when = cap.when
|
||||
if when is not None:
|
||||
title += f" · {when.strftime('%d %b %H:%M:%S')}"
|
||||
title += f" · {_stamp(when)}"
|
||||
return Panel(body, title=title, title_align="left",
|
||||
subtitle=f"[bright_black]{where} ↑↓ scroll "
|
||||
f"⏎ play t or esc back[/bright_black]",
|
||||
|
|
@ -1673,8 +1719,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
p.add_argument("directory", nargs="?", default=None,
|
||||
help="where the recordings are "
|
||||
"(default: the scanner's output directory)")
|
||||
p.add_argument("--sort", choices=SORTS, default="time",
|
||||
help="initial order (default: time, newest first)")
|
||||
p.add_argument("--sort", choices=SORTS + tuple(SORT_ALIASES),
|
||||
default=SORTS[0], metavar="ORDER",
|
||||
help="initial order: %s (default: %s, newest first)"
|
||||
% (", ".join(SORTS), SORTS[0]))
|
||||
p.add_argument("--filter", default="", metavar="TEXT",
|
||||
help="start with only recordings matching this")
|
||||
p.add_argument("--player", default=None, metavar="CMD",
|
||||
|
|
@ -1782,7 +1830,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
player = Player(args.player.split() if args.player else None)
|
||||
book = CallsignBook(online=args.lookup)
|
||||
browser = Browser(directory, console=console, player=player, book=book)
|
||||
browser.sort = args.sort
|
||||
browser.sort = SORT_ALIASES.get(args.sort, args.sort)
|
||||
browser.query = args.filter
|
||||
browser.apply()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
||||
.TH BANDSAUNTER 1 "2026-08-29" "bandsaunter 2026-08-29_03" "User Commands"
|
||||
.TH BANDSAUNTER 1 "2026-08-30" "bandsaunter 2026-08-30_01" "User Commands"
|
||||
.SH NAME
|
||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||
.SH SYNOPSIS
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ is the part you actually want to read; underneath it are the identification,
|
|||
the bands the frequency falls in, and the list itself. Pressing Enter plays
|
||||
the recording.
|
||||
.PP
|
||||
Each line of the list gives the frequency, the date and time, the mode, how
|
||||
long it ran and what was said. The moment is written
|
||||
.BR "YY-mm-dd hh:mm:ss am/pm" ,
|
||||
date first so that a column of them reads down in order, and on a twelve-hour
|
||||
clock so that it reads the way you would say it. The list starts in that
|
||||
order, newest first.
|
||||
.PP
|
||||
With no
|
||||
.I DIRECTORY
|
||||
it opens the one the scanner writes to, taken from your saved settings, so it
|
||||
|
|
@ -139,8 +146,12 @@ Where the recordings are. Defaults to the scanner's output directory, or to
|
|||
when that is set.
|
||||
.TP
|
||||
.BI \-\-sort " ORDER"
|
||||
Start in this order: {sorts}. Time is newest first; duration is longest
|
||||
Start in this order: {sorts}. Date/time is newest first, ordered by the whole
|
||||
moment \[em] year, month, day, then hour, minute and second; duration is longest
|
||||
first.
|
||||
.B time
|
||||
is accepted as the old name for
|
||||
.BR date/time .
|
||||
.TP
|
||||
.BI \-\-filter " TEXT"
|
||||
Start with only the recordings matching this, exactly as if it had been typed
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
||||
.TH SAUNTERBROWSE 1 "2026-08-29" "bandsaunter 2026-08-29_03" "User Commands"
|
||||
.TH SAUNTERBROWSE 1 "2026-08-30" "bandsaunter 2026-08-30_01" "User Commands"
|
||||
.SH NAME
|
||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||
.SH SYNOPSIS
|
||||
|
|
@ -29,6 +29,13 @@ is the part you actually want to read; underneath it are the identification,
|
|||
the bands the frequency falls in, and the list itself. Pressing Enter plays
|
||||
the recording.
|
||||
.PP
|
||||
Each line of the list gives the frequency, the date and time, the mode, how
|
||||
long it ran and what was said. The moment is written
|
||||
.BR "YY-mm-dd hh:mm:ss am/pm" ,
|
||||
date first so that a column of them reads down in order, and on a twelve-hour
|
||||
clock so that it reads the way you would say it. The list starts in that
|
||||
order, newest first.
|
||||
.PP
|
||||
With no
|
||||
.I DIRECTORY
|
||||
it opens the one the scanner writes to, taken from your saved settings, so it
|
||||
|
|
@ -69,7 +76,7 @@ so "was the repeater mentioned" is a question you can ask directly. Enter
|
|||
accepts, Escape clears.
|
||||
.TP
|
||||
.B s
|
||||
Cycle the order: time, frequency, duration.
|
||||
Cycle the order: date/time, frequency, duration.
|
||||
.TP
|
||||
.B r
|
||||
Re-read the directory. A scan running in another window is still writing to
|
||||
|
|
@ -113,8 +120,12 @@ Where the recordings are. Defaults to the scanner's output directory, or to
|
|||
when that is set.
|
||||
.TP
|
||||
.BI \-\-sort " ORDER"
|
||||
Start in this order: time, frequency, duration. Time is newest first; duration is longest
|
||||
Start in this order: date/time, frequency, duration. Date/time is newest first, ordered by the whole
|
||||
moment \[em] year, month, day, then hour, minute and second; duration is longest
|
||||
first.
|
||||
.B time
|
||||
is accepted as the old name for
|
||||
.BR date/time .
|
||||
.TP
|
||||
.BI \-\-filter " TEXT"
|
||||
Start with only the recordings matching this, exactly as if it had been typed
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ def test_the_cursor_stays_on_screen_in_a_long_list(tmp_path):
|
|||
|
||||
def test_sorting_cycles_and_reorders(library):
|
||||
b = browser(library)
|
||||
assert b.sort == "time"
|
||||
assert b.sort == "date/time"
|
||||
b.handle("s")
|
||||
assert b.sort == "frequency"
|
||||
assert [c.frequency for c in b.view] == sorted(c.frequency
|
||||
|
|
@ -387,6 +387,85 @@ def test_sorting_cycles_and_reorders(library):
|
|||
assert b.view[0].duration >= b.view[-1].duration
|
||||
|
||||
|
||||
def test_the_listing_gives_the_date_as_well_as_the_time(library):
|
||||
"""Without the date, two recordings a week apart look like neighbours."""
|
||||
text = frame(browser(library))
|
||||
assert re.search(r"\d\d-\d\d-\d\d \d\d:\d\d:\d\d [ap]m", text)
|
||||
|
||||
|
||||
def test_the_clock_is_twelve_hour_with_midnight_and_noon_named(tmp_path):
|
||||
"""The two hours a twelve-hour clock gets wrong if it is done by
|
||||
subtraction: midnight is 12 am and noon is 12 pm, not 0 am and 0 pm."""
|
||||
make_capture(tmp_path, 146.52, "2026-08-30_00_00_30", "nfm")
|
||||
make_capture(tmp_path, 146.52, "2026-08-30_12_00_30", "nfm")
|
||||
make_capture(tmp_path, 146.52, "2026-08-30_13_05_00", "nfm")
|
||||
text = frame(browser(tmp_path))
|
||||
assert "26-08-30 12:00:30 am" in text
|
||||
assert "26-08-30 12:00:30 pm" in text
|
||||
assert "26-08-30 01:05:00 pm" in text
|
||||
|
||||
|
||||
def test_sorting_by_date_time_is_chronological_across_every_boundary(tmp_path):
|
||||
"""Year, month, day, then hour, minute, second. The order below is the
|
||||
one a clock would put them in; the browser has to agree with it whichever
|
||||
way the filenames happen to sort as text."""
|
||||
moments = ["2025-12-31_23_59_59", "2026-01-01_00_00_01",
|
||||
"2026-01-01_00_00_02", "2026-01-31_09_00_00",
|
||||
"2026-02-01_08_00_00", "2026-08-30_11_59_59",
|
||||
"2026-08-30_12_00_00", "2026-08-30_13_00_00"]
|
||||
for i, when in enumerate(moments):
|
||||
make_capture(tmp_path, 146.52 + i * 0.01, when, "nfm")
|
||||
b = browser(tmp_path)
|
||||
assert [c.when.strftime("%Y-%m-%d_%H_%M_%S") for c in b.view] == \
|
||||
list(reversed(moments))
|
||||
|
||||
|
||||
def test_two_signals_in_the_same_second_keep_a_settled_order(tmp_path):
|
||||
"""A tie is broken by the filename so that the list does not shuffle
|
||||
itself between one reload and the next."""
|
||||
make_capture(tmp_path, 146.52, "2026-08-30_10_00_00", "nfm")
|
||||
make_capture(tmp_path, 145.00, "2026-08-30_10_00_00", "nfm")
|
||||
first = [c.path.name for c in browser(tmp_path).view]
|
||||
assert first == sorted(first)
|
||||
|
||||
|
||||
def test_the_sort_key_is_called_date_time(library):
|
||||
b = browser(library, width=140)
|
||||
assert b.sort == "date/time"
|
||||
assert "date/time" in frame(b)
|
||||
|
||||
|
||||
def test_the_old_name_for_the_time_sort_still_works(library, monkeypatch):
|
||||
"""--sort time is the sort of thing that lives in a shell alias."""
|
||||
from bandsaunter import browse
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_loop(self):
|
||||
seen["sort"] = self.sort
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(browse.Browser, "run", fake_loop, raising=False)
|
||||
monkeypatch.setattr(browse.Console, "is_terminal", property(lambda s: True))
|
||||
assert main(["--sort", "time", str(library)]) == 0
|
||||
assert seen["sort"] == "date/time"
|
||||
|
||||
|
||||
def test_the_frequency_column_does_not_change_width_while_scrolling(tmp_path):
|
||||
"""One wide frequency in the list fixes the column for the whole list, so
|
||||
that scrolling past it does not make everything else jump sideways."""
|
||||
make_capture(tmp_path, 1090.000001, "2026-08-30_10_00_00", "nfm")
|
||||
for i in range(40):
|
||||
make_capture(tmp_path, 146.52, f"2026-08-30_11_{i // 60:02d}_{i % 60:02d}",
|
||||
"nfm")
|
||||
b = browser(tmp_path, height=24)
|
||||
frame(b)
|
||||
was = b.freq_width
|
||||
b.handle("end")
|
||||
frame(b)
|
||||
assert b.freq_width == was
|
||||
|
||||
|
||||
def test_quitting_stops_the_loop(library):
|
||||
assert browser(library).handle("q") is False
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue