`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
551 lines
26 KiB
Python
551 lines
26 KiB
Python
"""The Contacts sweep: rank the whole contact table, archive the weakest off the device.
|
|
|
|
A companion's contact table is finite and a busy mesh fills it with whatever adverts
|
|
arrived — mostly nodes heard once, in passing, four hops out — until there is no room left
|
|
to discover anyone new. This is the flow that gets that headroom back, in three steps over
|
|
the pushed Contacts list:
|
|
|
|
1. **Pick a target**, from a ladder in two sections. *By standing* rungs keep the
|
|
strongest share of the table and archive the rest; *long silent* rungs sweep by
|
|
last-heard age — the plain, predictable operation a ranking can't express ("everything I
|
|
haven't heard in a year"). Both carry the real number of contacts they would archive,
|
|
computed from the actual ranking rather than from arithmetic on the table size, because
|
|
protected contacts are never victims.
|
|
2. **Read the list, and edit it.** Every contact the sweep would take, weakest first, with
|
|
its percentile and the two or three facts that put it there — for the age rungs too,
|
|
since knowing how the contacts an age threshold caught actually *rank* is exactly the
|
|
check that threshold cannot do for itself. ``Delete`` drops a row from the list, so a
|
|
contact the reader wants to keep is spared without abandoning the whole sweep and picking
|
|
a shallower rung; ``←→`` scroll a row too wide for the terminal. Enter on a contact opens
|
|
its detail page to *look* at, with the page's own contact-management verbs withheld (see
|
|
:func:`~meshterm.ui.node_detail_screen.open_node_detail`) — a screen for choosing what to
|
|
archive should not also hand out a singular archive or delete from inside its own
|
|
candidate list. It ends in the Apply/Back pair that
|
|
:func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because that is what this
|
|
is: a choice with a cost on both sides, not an exit.
|
|
3. **Confirm.** An amber Cancel/Purge dialog, not the red typed gate a deletion gets: this
|
|
is reversible. Every contact keeps its key, its reception history and its transcripts,
|
|
the Archived list is one row away on the Contacts screen, and a restore is a single
|
|
write. Saving the red for what cannot be undone is what keeps the red meaning anything.
|
|
|
|
**Only the percentile is ever shown.** A raw score means nothing without the distribution
|
|
it came from, and would need a legend the moment the weights were ever retuned; a contact's
|
|
rank against your other contacts explains itself. See :mod:`~meshterm.core.contact_score`
|
|
for the scoring itself, which is pure and lives in core.
|
|
|
|
**Archived, not deleted.** The sweep removes each contact from the *device* — freeing the
|
|
slot, which is the whole point — and records it in the cross-session store with an archive
|
|
stamp (see :meth:`~meshterm.core.contact_store.ContactStore.archive`). Nothing else moves:
|
|
the node's reception history, its position on the map, its Time Machine record and its
|
|
direct-message transcript are all keyed by node id rather than by a contact row and are
|
|
untouched. Because the store keeps the full public key, one write puts a contact back —
|
|
which is exactly what the chat screen already offers when a send is rejected for a
|
|
recipient the device no longer holds
|
|
(:class:`~meshterm.core.connection.ContactNotOnDeviceError`), so an archived contact you
|
|
message anyway is restored in passing rather than being a dead end.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import time
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
from rich.text import Text
|
|
|
|
from ..core.connection import ContactNotOnDeviceError
|
|
from ..core.contact_score import (
|
|
ScoredContact,
|
|
rank_contacts,
|
|
sweep_candidates,
|
|
)
|
|
from ..core.models import Contact
|
|
from .menus import Lane, column_header, fit_cells, menu_rows, section_heading
|
|
from .theme import name_style
|
|
from .tui import Choice, Separator
|
|
from .tui.screen import CANCEL
|
|
|
|
if TYPE_CHECKING:
|
|
from ..context import AppContext
|
|
|
|
#: The keep-the-strongest rungs the target picker offers, as a share of the *sweepable*
|
|
#: contacts. Percentages rather than absolute counts because the score itself is only ever
|
|
#: read as a percentile: a rung and the lane it filters against then speak one language, and
|
|
#: the ladder means the same thing on a table of thirty and a table of three hundred without
|
|
#: a single value needing to be retuned. Coarse at the aggressive end — the difference
|
|
#: between keeping 90% and 75% is a tidy-up, between 50% and 25% a decision.
|
|
KEEP_RUNGS: tuple[int, ...] = (90, 75, 60, 50, 25)
|
|
|
|
#: The sweep's own tail sentinels, distinct from any :class:`ScoredContact`.
|
|
_APPLY = ("apply",)
|
|
_BACK = ("back",)
|
|
|
|
#: A day in seconds, for the age ladder.
|
|
_DAY = 86400
|
|
|
|
#: The last-heard rungs, ``(label, min_age_seconds)``. A contact qualifies when its age is
|
|
#: *at least* that long; a never-heard contact is excluded here and swept only by
|
|
#: :data:`_NEVER`, so an age rung can't quietly take a contact that simply hasn't adverted
|
|
#: yet. The same ladder the age-only purge offered before this flow absorbed it.
|
|
AGE_RUNGS: tuple[tuple[str, int], ...] = (
|
|
("Not heard in 1 week", 7 * _DAY),
|
|
("Not heard in 1 month", 30 * _DAY),
|
|
("Not heard in 3 months", 90 * _DAY),
|
|
("Not heard in 6 months", 180 * _DAY),
|
|
("Not heard in 1 year", 365 * _DAY),
|
|
)
|
|
|
|
#: The age-rung value meaning the never-heard bucket (contacts with no advert time at all).
|
|
_NEVER = -1
|
|
|
|
#: Percentile lane width, plus its gap — three digits and two cells of air.
|
|
_PCTL_W = 5
|
|
|
|
#: Name lane width in the preview, plus its gap. Wide enough for the 20-odd bytes a
|
|
#: MeshCore advert name runs to, and narrow enough to leave the reasons lane readable at
|
|
#: the PicoCalc's 53 columns.
|
|
_NAME_W = 22
|
|
|
|
|
|
def _pctl_style(percentile: int) -> str:
|
|
"""The style a percentile is drawn in — the same quality reading ``snr_style`` gives SNR.
|
|
|
|
Three bands rather than a gradient: the heat ramp is spoken for (it colours *ages*, and
|
|
a second gradient in a neighbouring lane would read as the same scale), and a percentile
|
|
in a purge preview only ever answers one question — is this contact near the bottom?
|
|
"""
|
|
if percentile >= 66:
|
|
return "ok"
|
|
if percentile >= 33:
|
|
return "warn"
|
|
return "err"
|
|
|
|
|
|
def _pctl_cell(percentile: int) -> Text:
|
|
"""One right-aligned percentile lane: the number alone, coloured by band."""
|
|
return Text(f"{percentile:>3} ", style=_pctl_style(percentile))
|
|
|
|
|
|
def _preview_header(width: int) -> str:
|
|
"""The preview's pinned column header, fitted to the terminal."""
|
|
return column_header(
|
|
[
|
|
Lane("PCTL", _PCTL_W),
|
|
Lane("NAME", _NAME_W),
|
|
Lane(("WHY IT RANKS LOW", "WHY")),
|
|
],
|
|
width,
|
|
)
|
|
|
|
|
|
def _victim_row(scored: ScoredContact) -> Text:
|
|
"""One preview line: percentile, the contact's name in its own hue, then why it ranks low.
|
|
|
|
The name keeps its key-derived colour like everywhere else in the app — this is a list
|
|
of nodes, and a reader picking one out of thirty rows should not have to read it letter
|
|
by letter because the screen it is on happens to be about deleting things.
|
|
"""
|
|
contact = scored.contact
|
|
row = _pctl_cell(scored.percentile)
|
|
name = contact.name or contact.key_prefix or "?"
|
|
# Through ``fit_cells``, so an over-long name ends on an ellipsis that says it was
|
|
# shortened — and so the lane is measured in display cells, which is the only measure
|
|
# that keeps the column straight once a name carries a wide glyph.
|
|
row.append(
|
|
fit_cells(name, _NAME_W - 2) + " ",
|
|
style=name_style(name, contact.public_key or contact.key_prefix),
|
|
)
|
|
row.append(" · ".join(scored.reasons), style="muted")
|
|
return row
|
|
|
|
|
|
def _rung_desc(kept: int, archived: int) -> str:
|
|
"""A rung's description lane, in the number the reader is actually choosing.
|
|
|
|
**How many contacts you keep**, not how many go. The device's contact table is the
|
|
scarce thing and the whole reason to sweep at all, so the useful number is the one that
|
|
has to fit in it — how many are archived is the arithmetic left over, and it follows in
|
|
the muted half rather than leading. A percentile cutoff led this lane for one round and
|
|
was the wrong unit twice over: it answered a question nobody asked at this step, and at
|
|
53 columns it did not fit anyway.
|
|
"""
|
|
if not archived:
|
|
return f"keeps all {kept}"
|
|
return f"keeps {kept} · archives {archived}"
|
|
|
|
|
|
async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]:
|
|
"""Gather every signal these contacts have and rank them, strongest first.
|
|
|
|
Five grouped scans of the history (:meth:`
|
|
~meshterm.persistence.repository.Repository.contact_signals`), one pass over the channel
|
|
transcript for name attribution, and the two stores that carry the explicit protections
|
|
— the Watchtower's stars and the remembered admin logins. Our own position comes from
|
|
the device's self-info, and is simply absent on a device that advertises none, which the
|
|
distance term reads as unknown for everybody.
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
contacts: The device's contacts (our own node is not among them).
|
|
|
|
Returns:
|
|
The full ranking, strongest first.
|
|
"""
|
|
from ..core.contact_score import _node_id
|
|
|
|
nodes = [_node_id(c) for c in contacts]
|
|
signals = ctx.repo.contact_signals(nodes)
|
|
|
|
# Channel attribution is by display name — a channel frame carries no sender key — so a
|
|
# name two contacts share attributes to neither of them. Counting it for both would
|
|
# award one node's standing to another; counting it for the first would pick by list
|
|
# order. Both stay *unattributed*, which the score reads as unknown rather than as
|
|
# silence, and the median fill puts them where an average contact sits.
|
|
posts = ctx.repo.channel_post_counts()
|
|
seen: dict[str, int] = {}
|
|
for contact in contacts:
|
|
folded = (contact.name or "").strip().casefold()
|
|
if folded:
|
|
seen[folded] = seen.get(folded, 0) + 1
|
|
|
|
watch = getattr(ctx, "watch_store", None)
|
|
admin = getattr(ctx, "admin_store", None)
|
|
for contact, node in zip(contacts, nodes, strict=True):
|
|
found = signals.get(node)
|
|
if found is None:
|
|
continue
|
|
folded = (contact.name or "").strip().casefold()
|
|
unique = bool(folded) and seen.get(folded) == 1
|
|
signals[node] = replace(
|
|
found,
|
|
channel_posts=posts.get(folded, 0) if unique else 0,
|
|
channel_attributed=unique,
|
|
watched=bool(watch is not None and watch.is_watched(node)),
|
|
has_admin=bool(admin is not None and admin.get(contact)),
|
|
)
|
|
|
|
self_lat = self_lon = None
|
|
try:
|
|
info = await ctx.devstate.self_info()
|
|
lat, lon = info.get("adv_lat"), info.get("adv_lon")
|
|
# A device that has never had a position set advertises 0/0, which is a point in the
|
|
# Atlantic rather than a location — the same reading the node detail page takes.
|
|
if lat and lon:
|
|
self_lat, self_lon = float(lat), float(lon)
|
|
except Exception as exc: # noqa: BLE001 - an unplaced device just loses one weak term
|
|
ctx.log.debug("purge: no self position (%s); distance term stays unknown", exc)
|
|
|
|
return rank_contacts(contacts, signals, self_lat=self_lat, self_lon=self_lon)
|
|
|
|
|
|
async def purge_contacts(ctx: AppContext, self_key: str) -> int:
|
|
"""Run the whole sweep — rank, pick a target, preview, confirm, archive. Returns the count.
|
|
|
|
Runs over the pushed Contacts list as its backdrop, so every step floats and Esc walks
|
|
back out one frame at a time. Each step is a real step back: Esc from the preview
|
|
returns to the ladder with the reader's rung still highlighted, rather than abandoning
|
|
the flow and making them start over.
|
|
|
|
The ranking is computed **once**, before the ladder is drawn, and reused for every rung
|
|
and both routes — it is five scans of the history, and a rung's count would be a lie if
|
|
it were measured against a different pass than the preview it opens.
|
|
|
|
Args:
|
|
ctx: Shared application context (interactive menu).
|
|
self_key: The device's own public key (hex) — how the contact store scopes this
|
|
device's remembered contacts.
|
|
|
|
Returns:
|
|
How many contacts were archived (``0`` if cancelled or nothing matched).
|
|
"""
|
|
from .surface import TuiUi
|
|
|
|
assert isinstance(ctx.ui, TuiUi) # guaranteed by the Contacts screen
|
|
session = ctx.ui.session
|
|
|
|
async with ctx.ui.busy_overlay("Rating contacts"):
|
|
contacts = await ctx.devstate.contacts()
|
|
ranked = await _rank(ctx, contacts)
|
|
|
|
sweepable = [scored for scored in ranked if not scored.protected]
|
|
if not sweepable:
|
|
ctx.ui.note("[muted]every contact is protected — nothing to sweep[/muted]")
|
|
return 0
|
|
|
|
while True:
|
|
picked = await session.run_screen(_target_screen(ranked, sweepable))
|
|
if picked is CANCEL or picked is None:
|
|
return 0
|
|
victims = victims_for(ranked, sweepable, picked)
|
|
if not victims:
|
|
ctx.ui.note("[muted]nothing matches that — no contacts to archive[/muted]")
|
|
continue
|
|
if await _preview(ctx, victims):
|
|
return await _sweep(ctx, self_key, victims)
|
|
|
|
|
|
def _age_seconds(scored: ScoredContact) -> float | None:
|
|
"""A scored contact's last-heard age in seconds, or ``None`` if it was never heard."""
|
|
days = scored.signals.heard_age_days
|
|
return None if days is None else days * _DAY
|
|
|
|
|
|
def victims_for(
|
|
ranked: list[ScoredContact],
|
|
sweepable: list[ScoredContact],
|
|
picked: tuple,
|
|
) -> list[ScoredContact]:
|
|
"""The contacts one rung would archive, weakest first.
|
|
|
|
Weakest first on **both** routes, the age ones included: the preview is read top-down,
|
|
and a reader who skims only its first screen should be seeing the contacts they are
|
|
least likely to want back — not whichever ones the age filter happened to list first.
|
|
|
|
Args:
|
|
ranked: The full ranking, strongest first.
|
|
sweepable: Its unprotected subset, in the same order.
|
|
picked: The rung's value — ``("keep", share)`` or ``("age", seconds)``.
|
|
|
|
Returns:
|
|
The victims, weakest first.
|
|
"""
|
|
route, value = picked
|
|
if route == "keep":
|
|
# Rounded *up*: "keep the strongest 90%" of five contacts keeps five, because you
|
|
# cannot keep four and a half and the safe direction on a keep is more. Python's
|
|
# ``round`` is banker's rounding, which quietly made the 90% and 75% rungs identical
|
|
# on a five-contact table by sending 4.5 and 3.75 to the same 4.
|
|
return sweep_candidates(ranked, keep=math.ceil(len(sweepable) * int(value) / 100))
|
|
matched = []
|
|
for scored in sweepable:
|
|
age = _age_seconds(scored)
|
|
if value == _NEVER:
|
|
if age is None:
|
|
matched.append(scored)
|
|
elif age is not None and age >= value:
|
|
matched.append(scored)
|
|
return list(reversed(matched))
|
|
|
|
|
|
def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
|
|
"""Build the two-section ladder, every rung carrying its own real archive count.
|
|
|
|
Grouped through :func:`~meshterm.ui.menus.section_heading` rather than drawn as two
|
|
lists, so the headings pin as the ladder scrolls and the section jumps step by them —
|
|
and so the two routes read as two ways of answering one question rather than as two
|
|
separate features that happen to share a screen.
|
|
"""
|
|
from .tui import SelectScreen
|
|
|
|
protected = len(ranked) - len(sweepable)
|
|
|
|
def rung(label: str, value: tuple) -> tuple:
|
|
victims = victims_for(ranked, sweepable, value)
|
|
# What remains on the device: every protected contact, plus the sweepable ones this
|
|
# rung doesn't take. Counted from the ranking rather than from the rung's own
|
|
# percentage, because a protection is never a victim and the two would disagree.
|
|
return (label, _rung_desc(len(ranked) - len(victims), len(victims)), value)
|
|
|
|
items: list = [section_heading("By standing")]
|
|
items += menu_rows(
|
|
[rung(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS]
|
|
)
|
|
items.append(section_heading("Long silent"))
|
|
items += menu_rows(
|
|
[rung(label, ("age", secs)) for label, secs in AGE_RUNGS]
|
|
+ [rung("Never heard at all", ("age", _NEVER))]
|
|
)
|
|
held = f" · {protected} protected" if protected else ""
|
|
return SelectScreen(
|
|
f"Purge contacts — {len(ranked)} known{held}",
|
|
items,
|
|
prompt=(
|
|
"Archives the weakest off the device, keeping them here. Ranked on messages, "
|
|
"how lately and often heard, hops and distance."
|
|
),
|
|
footer_hint="↑↓ move · Enter select · Esc back",
|
|
filterable=False,
|
|
)
|
|
|
|
|
|
def _preview_items(victims: list[ScoredContact]) -> list:
|
|
"""The preview's rows: a pinned column header, one line per victim, then Apply/Back.
|
|
|
|
Each contact row is ``deletable``, so ``Delete`` lifts it out of the sweep, and pins its
|
|
percentile lane out of the ``←→`` scroll (``hscroll_from``): the number is the reader's
|
|
place in a list ordered by it, and sliding it away to read a long reasons lane would
|
|
cost the row its bearing to gain nothing — the percentile is the part that already fits.
|
|
"""
|
|
return [
|
|
Separator(_preview_header, pinned=True),
|
|
*(
|
|
Choice(title=_victim_row(v), value=v, deletable=True, hscroll_from=_PCTL_W)
|
|
for v in victims
|
|
),
|
|
# The same shape ``exit_rows`` draws, in this flow's own words: Apply has no key of
|
|
# its own, so it needs a visible counterpart naming what the other way out costs.
|
|
# Not ``exit_rows`` itself — these are not *staged changes* to a set of values, and
|
|
# the pair should say what it actually archives.
|
|
Separator(" "),
|
|
Choice(
|
|
title=Text.assemble(("✓ ", "ok"), f"Archive {_count_desc(len(victims))}"),
|
|
value=_APPLY,
|
|
),
|
|
Choice(title=Text.assemble(("✗ ", "err"), "Back — keep them all"), value=_BACK),
|
|
]
|
|
|
|
|
|
def _preview_screen(victims: list[ScoredContact]):
|
|
"""The preview screen over a victim list, sized and worded to what it currently holds."""
|
|
from .tui import SelectScreen
|
|
|
|
return SelectScreen(
|
|
f"Purge contacts — {_count_desc(len(victims))} to archive",
|
|
_preview_items(victims),
|
|
prompt="Archived contacts leave the device but stay here, with their history.",
|
|
# Neither the scroll nor the keep atom is written into the base hint: the select
|
|
# list splices each in exactly when its key would act — ``←→`` only while the
|
|
# highlighted row actually overflows, ``Del keep`` only on a row that can be spared
|
|
# — which is the same "advertise a key only where it acts" rule the whole footer
|
|
# follows, and what keeps all three atoms inside 72 cells at once.
|
|
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
|
|
delete_hint="Del keep",
|
|
default=_APPLY,
|
|
hscroll=True,
|
|
hscroll_hint="←→ scroll",
|
|
)
|
|
|
|
|
|
async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
|
|
"""Show exactly who would go, weakest first; return whether the reader committed.
|
|
|
|
The audit step, and the reason the sweep is safe to offer at all: archiving several
|
|
dozen contacts on a number nobody can check is precisely the operation that deserves to
|
|
be looked at once. The list is filterable like any other — a reader who wants to know
|
|
whether one particular node is in it should be able to type its name.
|
|
|
|
It is also **editable**. ``Delete`` drops a contact out of the sweep, because the
|
|
alternative was to back out and pick a shallower rung, which spares the one contact you
|
|
recognised by also sparing forty you didn't care about. The row list is *data* here, so
|
|
it refreshes through
|
|
:meth:`~meshterm.ui.tui.select.SelectScreen.replace_items` — the highlight follows by
|
|
value and the typed filter holds — rather than by rebuilding the screen and dropping the
|
|
reader back at the top of a list they were halfway down.
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
victims: The candidates, weakest first. **Mutated** when the reader drops rows, so
|
|
the caller's list is what actually gets swept.
|
|
|
|
Returns:
|
|
Whether the reader committed to archiving whatever survived their edits.
|
|
"""
|
|
from .node_detail_screen import open_node_detail
|
|
from .tui.select import DeleteRequest
|
|
|
|
screen = _preview_screen(victims)
|
|
# A hub: it owns a loop, so it is pushed once and stays for the whole visit. That is what
|
|
# lets a spared row refresh the rows underneath the reader's cursor, and a detail page
|
|
# nest above without this screen losing its place.
|
|
async with ctx.ui.session.stay(screen) as visit:
|
|
while True:
|
|
chosen = await visit.result()
|
|
if chosen is CANCEL or chosen is None or chosen == _BACK:
|
|
return False
|
|
if chosen == _APPLY:
|
|
return bool(victims)
|
|
if isinstance(chosen, DeleteRequest):
|
|
spared = chosen.value
|
|
if spared in victims:
|
|
victims.remove(spared)
|
|
if not victims:
|
|
# The reader spared every candidate: there is no sweep left to preview,
|
|
# and an empty list under an "Archive no contacts" row would be a screen
|
|
# asking them to confirm nothing.
|
|
return False
|
|
# The title counts the victims, so it is content too and moves with them.
|
|
screen.replace_items(
|
|
_preview_items(victims),
|
|
title=f"Purge contacts — {_count_desc(len(victims))} to archive",
|
|
)
|
|
continue
|
|
# Any other row is a contact: Enter opens its node detail page, so a reader who
|
|
# doesn't recognise a name can go and look before archiving it. The page is
|
|
# opened with ``manage=False`` — this screen is already where archiving is being
|
|
# decided, and a second, singular archive or delete reachable from inside its own
|
|
# candidate list would be two answers to one question. The preview stays pushed
|
|
# underneath, so Esc from the page lands back on the row it was opened from.
|
|
await open_node_detail(ctx, chosen.contact, manage=False)
|
|
|
|
|
|
def _count_desc(n: int) -> str:
|
|
"""``no contacts`` / ``1 contact`` / ``n contacts`` — the sweep's counting phrase."""
|
|
if n == 0:
|
|
return "no contacts"
|
|
return f"{n} contact{'' if n == 1 else 's'}"
|
|
|
|
|
|
async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -> int:
|
|
"""Confirm, then remove each victim from the device and archive it here. Returns the count.
|
|
|
|
The confirm is **amber and takes no typing**, unlike the red typed gate a bulk deletion
|
|
wears. The two tiers exist for two different costs and this one is recoverable — every
|
|
contact keeps its key, its reception history and its transcripts, and a restore is one
|
|
write from the Archived list. A red typed confirm here would have spent the app's
|
|
loudest warning on its most reversible bulk action, which is how a warning stops meaning
|
|
anything.
|
|
|
|
The removal is a companion-local command per contact — no LoRa traffic — so it runs
|
|
straight through under the progress dialog rather than being paced. The store write
|
|
follows each successful removal rather than being batched at the end, so an interrupted
|
|
sweep leaves a consistent state: every contact off the device is recorded as archived.
|
|
"""
|
|
if not await ctx.ui.dialog(
|
|
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
|
|
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
|
|
"history and messages — under Archived contacts, and any of them can be restored.",
|
|
[("Cancel", False), ("Purge", True)],
|
|
title="Purge contacts",
|
|
default=1,
|
|
danger=True,
|
|
):
|
|
return 0
|
|
|
|
device = await ctx.device()
|
|
dev_pub = (self_key or "").lower().removeprefix("0x")
|
|
store = ctx.contact_store
|
|
stamp = int(time.time())
|
|
archived = failed = 0
|
|
with ctx.ui.progress("Purge contacts") as progress:
|
|
task = progress.add_task("Archiving", total=len(victims))
|
|
for scored in victims:
|
|
contact = scored.contact
|
|
try:
|
|
await device.remove_contact(contact)
|
|
except ContactNotOnDeviceError:
|
|
# Already absent from the radio: the sweep's device-side work is done, and
|
|
# archiving it here is what makes the row go away. Not a failure.
|
|
ctx.log.debug("purge: %s was not on the device; archiving ours", contact.name)
|
|
except Exception as exc: # noqa: BLE001 - one bad removal mustn't abort the sweep
|
|
failed += 1
|
|
ctx.log.debug("purge: could not remove %s: %s", contact.name, exc)
|
|
continue
|
|
finally:
|
|
progress.advance(task)
|
|
if store is not None and dev_pub:
|
|
store.archive(dev_pub, contact, when=stamp)
|
|
archived += 1
|
|
ctx.devstate.invalidate_contacts()
|
|
|
|
if archived and not failed:
|
|
outcome = Text(f"✓ archived {_count_desc(archived)}", style="ok")
|
|
elif archived:
|
|
outcome = Text(
|
|
f"archived {_count_desc(archived)}; {failed} could not be removed", style="warn"
|
|
)
|
|
else:
|
|
outcome = Text("no contacts could be archived", style="err")
|
|
await ctx.ui.session.message_dialog(outcome, title="Purge contacts")
|
|
return archived
|