The same key as in a conversation. The row gains or loses its padlock in place, with no confirm, and the next ^K reverses it. On the handhelds, Lock (or Unlock) rides Shift+F2, as in a channel and a DM. The hint names ^K only where it fits without dropping another atom. That rule now lives in one helper, menus.splice_if_room, which the archive preview uses too. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
906 lines
43 KiB
Python
906 lines
43 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The Contacts sweep: rank the full contact table, and archive the weakest off the device.
|
|
|
|
The contact table of a companion has a fixed size. A busy mesh fills it with the adverts
|
|
that arrive, until the table has no space left to discover a new node. Most of these
|
|
adverts come from nodes that were heard only one time, in passing, four hops away. This
|
|
flow gets that free space back, in three steps over the pushed Contacts list:
|
|
|
|
1. **Select a target**, from a ladder in two sections. The "By standing" rungs keep the
|
|
strongest part of the table and archive the remainder. The "Long silent" rungs sweep by
|
|
last-heard age. That is the plain, predictable operation that a ranking cannot express
|
|
("everything I haven't heard in a year"). Each rung of the two types shows the real
|
|
number of contacts that it archives. MeshTerm calculates this number from the actual
|
|
ranking, not from arithmetic on the table size, because a protected contact is never a
|
|
victim.
|
|
2. **Read the list, and edit it.** The list shows each contact that the sweep will take,
|
|
weakest first. Lanes beside each contact show the evidence: how recently it was heard,
|
|
what it sent, whether you ever sent a message to it, and how far away it is. The age
|
|
rungs show this list too. An age threshold cannot check itself, and to see what the
|
|
contacts that it caught did is exactly that check. ``Delete`` removes a row from the
|
|
list. Thus the user can spare one contact, and does not have to cancel the full sweep
|
|
and select a less aggressive rung. ``Shift+Delete`` also removes the row, and it stages
|
|
a lock on the contact. Apply writes the lock, and then no later sweep takes that
|
|
contact (refer to :meth:`~meshterm.core.contact_store.ContactStore.set_locked`). Back
|
|
and Esc discard the staged locks, after a confirm, as an editor discards its staged
|
|
values. ``←→`` scroll a row that is too wide for the terminal. Enter on a contact opens
|
|
its detail page, only to examine the contact. The page does not show its own verbs
|
|
that manage contacts (refer to
|
|
:func:`~meshterm.ui.node_detail_screen.open_node_detail`). A screen where the user
|
|
chooses what to archive must not also offer an archive or a delete of one contact from
|
|
inside its own list of candidates. A lock is not such a verb: it gives the same answer
|
|
as ``Delete``, for all the sweeps that follow. The list ends with the Apply/Back pair
|
|
that :func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because this list
|
|
is that type of choice: a choice with a cost on each side, not an exit.
|
|
3. **Confirm.** An amber Cancel/Archive dialog, not the red typed gate of a deletion,
|
|
because this operation is reversible. Each 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 one write. We keep the red for what cannot be undone. That is why the red
|
|
continues to have a meaning.
|
|
|
|
**The preview shows the evidence, never the verdict.** It does not show the score. A
|
|
weighted sum has no meaning without the distribution that it came from, and it needs a
|
|
legend each time that the weights change. It does not show the percentile either. The
|
|
percentile was the first lane of the row for one round. But a rank is the score's own
|
|
reading of itself, so a lane of percentiles repeated the order of the list and used five
|
|
cells to say "trust me". An audit needs the measurements, each in a lane of its own type.
|
|
Each lane that the Contacts list also has gets the same header as on that list. Thus a
|
|
column means one thing from top to bottom, and the user can check the sweep against what
|
|
they know, instead of against its arithmetic. Refer to :mod:`~meshterm.core.contact_score`
|
|
for the scoring itself. That module is pure, it is in core, and it still ranks the list.
|
|
|
|
**Archived, not deleted.** The sweep removes each contact from the device, which frees its
|
|
slot (that is the purpose of the sweep). It stores the contact in the cross-session store
|
|
with an archive stamp (refer to :meth:`~meshterm.core.contact_store.ContactStore.archive`).
|
|
Nothing else changes. The reception history of the node, its position on the map, its Time
|
|
Machine record, and its direct-message transcript are stored by node id, not by contact
|
|
row, so they stay as they are. The store keeps the full public key, so one write puts a
|
|
contact back. The chat screen already offers this write when the device rejects a send
|
|
because it does not hold the recipient
|
|
(:class:`~meshterm.core.connection.ContactNotOnDeviceError`). Thus, if you send a message
|
|
to an archived contact, MeshTerm restores the contact as part of the send, and the send
|
|
does not fail.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, replace
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from rich.cells import cell_len
|
|
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,
|
|
confirm_discard,
|
|
fit_cells,
|
|
section_heading,
|
|
splice_if_room,
|
|
)
|
|
from .theme import name_style
|
|
from .tui import Choice, SelectScreen, Separator
|
|
from .tui.screen import CANCEL
|
|
from .tui.select import DeleteRequest, KeyRequest
|
|
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, _recency_style, format_age
|
|
|
|
if TYPE_CHECKING:
|
|
from ..context import AppContext
|
|
|
|
#: The rungs that keep the strongest contacts, as the target ladder offers them. Each value
|
|
#: is a percentage of the sweepable contacts (the unprotected contacts), not of all the
|
|
#: contacts. We use percentages instead of counts, because only a percentage has the same
|
|
#: meaning on a table of thirty and on a table of three hundred. The ranking below it is a
|
|
#: rank, so a count would need a new value for each mesh, but a percentage never does. The
|
|
#: steps are large at the aggressive end: from 90% to 75% is a small clean-up, but from 50%
|
|
#: to 25% is a decision.
|
|
KEEP_RUNGS: tuple[int, ...] = (90, 75, 60, 50, 25)
|
|
|
|
#: The sentinel values of the two last rows of the preview. No :class:`ScoredContact` is
|
|
#: equal to them.
|
|
_APPLY = ("apply",)
|
|
_BACK = ("back",)
|
|
|
|
#: The token of the lock key, in the :class:`~meshterm.ui.tui.select.KeyRequest` that the
|
|
#: preview resolves with (refer to :class:`_PreviewScreen`).
|
|
_LOCK = "lock"
|
|
|
|
#: The footer atom of the lock key. The preview shows it only where the hint has room for it
|
|
#: (refer to :func:`~meshterm.ui.menus.splice_if_room`).
|
|
_LOCK_HINT = "⇧Del lock"
|
|
|
|
#: One day in seconds, for the age rungs.
|
|
_DAY = 86400
|
|
|
|
#: The last-heard rungs, as ``(label, min_age_seconds)``. A contact matches when its age is
|
|
#: equal to that value or more. These rungs do not include a contact that was never heard.
|
|
#: Only :data:`_NEVER` sweeps such a contact, so that an age rung does not silently take a
|
|
#: contact that did not broadcast an advert yet. This is the same ladder that the age-only
|
|
#: sweep offered before this flow included 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 value of the age rung for the contacts that were never heard (the contacts with no
|
|
#: advert time at all).
|
|
_NEVER = -1
|
|
|
|
#: The gap between two lanes, in cells. This space keeps a right-aligned value apart from
|
|
#: the value before it. The declared width of a lane is the width of its content plus this
|
|
#: gap.
|
|
_GAP = 2
|
|
|
|
#: The width of the name lane in the preview, with its gap. It is wide enough for the
|
|
#: approximately 20 bytes of a MeshCore advert name. It is also narrow enough that the
|
|
#: evidence lanes stay readable at the 53 cells of the PicoCalc.
|
|
_NAME_W = 22
|
|
|
|
#: The type-mark lane before the name: the glyph of the node and a space. The Contacts list
|
|
#: starts each row in the same way (refer to :func:`~meshterm.ui.contactlist._lane`).
|
|
_MARK_W = 2
|
|
|
|
|
|
def _count_cell(tally: int | None) -> tuple[str, str]:
|
|
"""A tally lane's value: the number in muted style, or a faint ``—`` if there is none.
|
|
|
|
This is exactly how the Contacts list reads ``count`` (refer to
|
|
:func:`~meshterm.ui.contactlist._lane_cell`), with the same clamp, so a very large tally
|
|
does not make a column wider. The user came here from that list, and must not have to
|
|
learn a second format for the same fact.
|
|
"""
|
|
if not tally:
|
|
return "—", "faint"
|
|
return f"{min(tally, 99999)}", "muted"
|
|
|
|
|
|
def _heard_cell(scored: ScoredContact) -> tuple[str, str]:
|
|
"""The last-heard lane: the column age in its recency heat, or a cold ``never``.
|
|
|
|
The lane uses :func:`~meshterm.ui.widgets.format_age` and the heat that the Contacts list
|
|
uses for its own ``HEARD`` lane. Thus one fact has one format, on each screen that shows
|
|
it.
|
|
"""
|
|
secs = age_seconds(scored)
|
|
return format_age(secs), _recency_style(secs)
|
|
|
|
|
|
def _hops_cell(scored: ScoredContact) -> tuple[str, str]:
|
|
"""The distance lane: ``direct`` for a neighbour, or else the mean hop count of its packets."""
|
|
hops = scored.signals.hops
|
|
if hops is None:
|
|
return "—", "faint"
|
|
return ("direct" if hops < 0.5 else f"{hops:g}"), "muted"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Evidence:
|
|
"""One evidence lane: the word above it, and how to read the value of a contact for it.
|
|
|
|
This is a declaration, not a branch, the same as
|
|
:class:`~meshterm.ui.contactlist.ContactLane` in the nearby module. The header builder,
|
|
the row builder, and the width arithmetic all go through the same tuple. Thus you add a
|
|
lane, or change the order of the lanes, in one place.
|
|
|
|
Attributes:
|
|
label: The header word. It is right-aligned above its lane, where its digits will be.
|
|
read: The value of the contact for this lane, as ``(text, style)``.
|
|
"""
|
|
|
|
label: str
|
|
read: Callable[[ScoredContact], tuple[str, str]]
|
|
|
|
|
|
#: The evidence lanes of the preview, from left to right. ``HEARD`` and ``PKTS`` are first,
|
|
#: in the order of the Contacts list and drawn in its format. The user came from that list,
|
|
#: and these are the two lanes that the user already read there. ``MSGS`` and ``HOPS`` come
|
|
#: next. The sweep adds these two lanes, and they are almost always empty in a list of
|
|
#: candidates (a contact that you sent messages to is almost never weak enough for the
|
|
#: sweep). Thus a terminal that is too narrow for the full row cuts the least useful end
|
|
#: first, and ``←→`` brings it back.
|
|
_EVIDENCE: tuple[_Evidence, ...] = (
|
|
_Evidence("HEARD", _heard_cell),
|
|
_Evidence("PKTS", lambda scored: _count_cell(scored.signals.packets)),
|
|
_Evidence("MSGS", lambda scored: _count_cell(scored.signals.dm_total)),
|
|
_Evidence("HOPS", _hops_cell),
|
|
)
|
|
|
|
|
|
def _lane_widths(victims: list[ScoredContact]) -> tuple[int, ...]:
|
|
"""The width of each evidence lane: its header word, or the widest value below it.
|
|
|
|
The function measures the widths from the list, not from fixed values. Thus a lane of
|
|
single digits is one cell of digits, not five cells of empty space. The widths are
|
|
measured again each time that a spared row leaves the list, and that can only make a
|
|
lane narrower.
|
|
"""
|
|
return tuple(
|
|
max([cell_len(lane.label), *(cell_len(lane.read(v)[0]) for v in victims)])
|
|
for lane in _EVIDENCE
|
|
)
|
|
|
|
|
|
def _preview_header(widths: tuple[int, ...], width: int) -> str:
|
|
"""The preview's pinned column header: the name lane, then one word for each evidence lane.
|
|
|
|
Each word is right-aligned in its lane, above values that are also right-aligned. Thus a
|
|
header is where its digits will be, not to the left of them. The Contacts list puts the
|
|
headers of the same two lanes in the same way.
|
|
|
|
One word for each lane is the purpose of this layout. Before, one ``WHY IT RANKS LOW``
|
|
header was above three lanes, and it could not name any of them. A lane held the *n*-th
|
|
weakest reason of a contact. Thus a message count, an age, and a hop count moved between
|
|
columns from row to row, and no column had the same meaning on two rows. Now each lane
|
|
has one fixed type of value. That makes the lanes comparable from the top to the bottom
|
|
of the list. It also lets the user see that the contact that they were not sure about is
|
|
the contact that never transmitted a packet.
|
|
"""
|
|
lanes = [Lane("NAME", _NAME_W)]
|
|
last = len(_EVIDENCE) - 1
|
|
for index, (lane, lane_w) in enumerate(zip(_EVIDENCE, widths, strict=True)):
|
|
lanes.append(Lane(f"{lane.label:>{lane_w}}", 0 if index == last else lane_w + _GAP))
|
|
# Start after the pointer and the type mark, so ``NAME`` is above the name, not the glyph.
|
|
return column_header(lanes, width, indent=2 + _MARK_W)
|
|
|
|
|
|
def _victim_row(scored: ScoredContact, widths: tuple[int, ...]) -> Text:
|
|
"""One preview line: the contact's type mark and name, then its evidence, lane by lane.
|
|
|
|
The row starts in the same way as a row of the Contacts list: first the shared type mark
|
|
in its own colour (``▲`` repeater, ``■`` room server, ``◉`` sensor, ``●`` companion), then
|
|
the name. Thus the contact looks the same here as on the list that the user came from.
|
|
Also, a repeater that MeshTerm is about to archive shows that it is a repeater before the
|
|
user reads its name.
|
|
|
|
The name keeps the colour that comes from its key, the same as everywhere else in the
|
|
app. This is a list of nodes. A user who looks for one node in thirty rows must not have
|
|
to read each name letter by letter only because this screen is about archives. The name
|
|
goes through :func:`~meshterm.ui.menus.fit_cells`. Thus a name that is too long ends with
|
|
an ellipsis that shows that it was cut, the lane is measured in cells, and a name with an
|
|
emoji is cut between glyphs, not through one glyph. A name is whatever the radio of a
|
|
stranger broadcast in its advert. It is the only value on this screen that can be
|
|
anything.
|
|
|
|
Each other lane is right-aligned in its measured width and has its own style: the heard
|
|
age has its recency heat, a tally is muted, and an absent value is a faint ``—``. The row
|
|
starts as an unstyled :class:`~rich.text.Text`, not with the style of the name, because
|
|
Rich merges a base style into each span. On a base style in the hue of a node, a faint
|
|
``—`` is faint, and in the colour of that node.
|
|
"""
|
|
contact = scored.contact
|
|
name = contact.name or contact.key_prefix or "?"
|
|
mark, mark_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
|
|
row = Text()
|
|
row.append(mark, style=mark_style)
|
|
row.append(" " * (_MARK_W - cell_len(mark)))
|
|
row.append(
|
|
fit_cells(name, _NAME_W - _GAP) + " " * _GAP,
|
|
style=name_style(name, contact.public_key or contact.key_prefix),
|
|
)
|
|
last = len(_EVIDENCE) - 1
|
|
for index, (lane, lane_w) in enumerate(zip(_EVIDENCE, widths, strict=True)):
|
|
value, style = lane.read(scored)
|
|
row.append(fit_cells(value, lane_w, align="right"), style=style)
|
|
if index != last:
|
|
row.append(" " * _GAP)
|
|
return row
|
|
|
|
|
|
#: The headers of the two count lanes of the ladder. Each lane has the same width as its
|
|
#: word, and the count is right-aligned in it. Thus a number ends below the last letter of
|
|
#: its header.
|
|
_KEEPS = "KEEPS"
|
|
_ARCHIVES = "ARCHIVES"
|
|
|
|
|
|
def _ladder_header(label_w: int, width: int) -> str:
|
|
"""The pinned column header of the target ladder, above the rung lane and its two counts."""
|
|
return column_header(
|
|
[
|
|
Lane("TARGET", label_w + 2),
|
|
Lane(_KEEPS, len(_KEEPS) + 2),
|
|
Lane(_ARCHIVES),
|
|
],
|
|
width,
|
|
)
|
|
|
|
|
|
def _rung_row(label: str, kept: int, archived: int, label_w: int) -> Text:
|
|
"""One rung: its label, then how many contacts it keeps and how many it archives.
|
|
|
|
**The kept count is first**, not the archived count. The contact table of the device has
|
|
limited space, and that limit is the only reason for a sweep. Thus the useful number is
|
|
the number that must fit in the table. The number of archived contacts is the remainder
|
|
of the arithmetic, and it comes second. A percentile cutoff was first here for one
|
|
round, and it was the wrong unit for two reasons: it answered a question that nobody
|
|
asked at this step, and it did not fit in 53 cells.
|
|
|
|
The two counts are columns, not a phrase (``keeps 45 · archives 12``). Thus the ladder
|
|
is a table from the top to the bottom. Each count is right-aligned below its own header.
|
|
To compare two rungs, the user looks down one lane, and does not have to find the number
|
|
in each sentence. A rung that archives no contacts shows its ``0`` muted. Thus the rungs
|
|
that do something are easy to see among the rungs that do nothing.
|
|
|
|
Args:
|
|
label: The label of the rung.
|
|
kept: The contacts that stay on the device, with the protected contacts.
|
|
archived: The contacts that the rung archives.
|
|
label_w: The width of the rung lane in cells: the widest label in the two sections.
|
|
"""
|
|
row = Text(label)
|
|
row.append(" " * (label_w - cell_len(label) + 2))
|
|
row.append(f"{kept:>{len(_KEEPS)}} ")
|
|
row.append(f"{archived:>{len(_ARCHIVES)}}", style=None if archived else "muted")
|
|
return row
|
|
|
|
|
|
async def _rank(ctx: AppContext, contacts: list[Contact], self_key: str) -> list[ScoredContact]:
|
|
"""Collect all the signals of these contacts, and rank the contacts, strongest first.
|
|
|
|
The function does five grouped scans of the history
|
|
(:meth:`~meshterm.persistence.repository.Repository.contact_signals`) and one pass over
|
|
the channel transcript to attribute names. It also reads the three stores that hold the
|
|
explicit protections: the contacts that you locked, the stars of the Watchtower, and the
|
|
remembered admin logins. The position of our node comes from the self-info of the
|
|
device. On a device that advertises no position, the position is absent, and the
|
|
distance term reads it as unknown for all contacts.
|
|
|
|
Args:
|
|
ctx: The shared application context.
|
|
contacts: The contacts of the device (our node is not one of them).
|
|
self_key: The public key of the device (hex). The locks are stored for this key.
|
|
|
|
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)
|
|
|
|
# MeshTerm attributes a channel post by the name of its sender, because a channel packet
|
|
# has no sender key. Thus a name that two contacts share is attributed to neither of
|
|
# them. If we counted it for both, one node would get the standing of another. If we
|
|
# counted it for the first, the list order would decide. Both contacts stay
|
|
# unattributed. The score reads that as unknown, not as silence, and the median fill
|
|
# puts them where an average contact is.
|
|
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
|
|
|
|
store = getattr(ctx, "contact_store", None)
|
|
locked = store.locked_keys(self_key) if store is not None and self_key else frozenset()
|
|
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)),
|
|
locked=(contact.public_key or "").lower().removeprefix("0x") in locked,
|
|
)
|
|
|
|
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 never had a position set advertises 0/0. That is a point in the
|
|
# Atlantic, not a real location. The node detail page reads it in the same way.
|
|
if lat and lon:
|
|
self_lat, self_lon = float(lat), float(lon)
|
|
except Exception as exc: # noqa: BLE001 - a device with no position loses only one weak term
|
|
ctx.log.debug("archive: no self position (%s); distance term stays unknown", exc)
|
|
|
|
return rank_contacts(contacts, signals, self_lat=self_lat, self_lon=self_lon)
|
|
|
|
|
|
async def archive_contacts(ctx: AppContext, self_key: str) -> int:
|
|
"""Run the full sweep: rank, select a target, preview, confirm, archive. Return the count.
|
|
|
|
The sweep runs over the pushed Contacts list as its backdrop. Thus each step floats, and
|
|
each Esc goes back one step. Each step back is a real step: Esc on the preview returns
|
|
to the ladder, where the user can select a different rung. The flow does not stop, and
|
|
the user does not have to start again.
|
|
|
|
The ranking is calculated **one time**, before the ladder is drawn. All the rungs, of the
|
|
two types, use it again. The ranking costs five scans of the history. Also, the count of
|
|
a rung would be false if it came from a different pass than the preview that the rung
|
|
opens.
|
|
|
|
Args:
|
|
ctx: The shared application context (interactive menu).
|
|
self_key: The public key of the device (hex). The contact store keeps the
|
|
remembered contacts of this device under this key.
|
|
|
|
Returns:
|
|
How many contacts were archived (``0`` if the user cancelled or no contact matched).
|
|
A sweep that only locked contacts also returns ``0``.
|
|
"""
|
|
from .surface import TuiUi
|
|
|
|
assert isinstance(ctx.ui, TuiUi) # the Contacts screen makes sure of this
|
|
session = ctx.ui.session
|
|
|
|
async with ctx.ui.busy_overlay("Rating contacts"):
|
|
contacts = await ctx.devstate.contacts()
|
|
ranked = await _rank(ctx, contacts, self_key)
|
|
|
|
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
|
|
# The store keeps a lock under the key of the device. Without the two, the preview has
|
|
# no lock key.
|
|
can_lock = getattr(ctx, "contact_store", None) is not None and bool(self_key)
|
|
|
|
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
|
|
locks: list[ScoredContact] | None = [] if can_lock else None
|
|
if await _preview(ctx, victims, locks):
|
|
return await _sweep(ctx, self_key, victims, locks or [])
|
|
|
|
|
|
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 that one rung archives, weakest first.
|
|
|
|
The order is weakest first for **both** types of rung, also for the age rungs. The user
|
|
reads the preview from the top down. A user who reads only its first screen must see the
|
|
contacts that they are least likely to want back. They must not see the contacts that
|
|
the age filter put first by chance.
|
|
|
|
Args:
|
|
ranked: The full ranking, strongest first.
|
|
sweepable: The unprotected contacts of the ranking, in the same order.
|
|
picked: The value of the rung: ``("keep", share)`` or ``("age", seconds)``.
|
|
|
|
Returns:
|
|
The victims, weakest first.
|
|
"""
|
|
route, value = picked
|
|
if route == "keep":
|
|
# Round up: "keep the strongest 90%" of five contacts keeps five. You cannot keep
|
|
# four and a half, and when you keep contacts, more is the safe direction. Python's
|
|
# ``round`` is banker's rounding. On a table of five contacts, it silently made the
|
|
# 90% and 75% rungs the same, because it changed 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 ladder of two sections. Each rung shows its own real archive count.
|
|
|
|
The sections are grouped with :func:`~meshterm.ui.menus.section_heading`, not drawn as
|
|
two lists. Thus the headings pin while the ladder scrolls, and the section jumps go from
|
|
one heading to the next. Also, the two types of rung look like two ways to answer one
|
|
question, not like two separate features that share a screen by chance.
|
|
"""
|
|
protected = len(ranked) - len(sweepable)
|
|
sections: list[tuple[str, list[tuple[str, tuple]]]] = [
|
|
(
|
|
"By standing",
|
|
[(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS],
|
|
),
|
|
(
|
|
"Long silent",
|
|
[(label, ("age", secs)) for label, secs in AGE_RUNGS]
|
|
+ [("Never heard at all", ("age", _NEVER))],
|
|
),
|
|
]
|
|
# One rung lane for the two sections. Thus the counts are in two columns from the top to
|
|
# the bottom of the ladder, and they do not start again below each heading.
|
|
label_w = max(cell_len(label) for _, rungs in sections for label, _ in rungs)
|
|
|
|
# Pinned like the header of the editors (menus.lane_header). Thus, when the ladder
|
|
# scrolls, its column words stay at the top, with the heading of its current section.
|
|
items: list = [Separator(lambda w: _ladder_header(label_w, w), pinned=True)]
|
|
for heading, rungs in sections:
|
|
items.append(section_heading(heading))
|
|
for label, value in rungs:
|
|
victims = victims_for(ranked, sweepable, value)
|
|
# The contacts that stay on the device: all the protected contacts, and the
|
|
# sweepable contacts that this rung does not take. We count them from the
|
|
# ranking, not from the percentage of the rung, because a protected contact is
|
|
# never a victim. Thus the two counts would not agree.
|
|
kept = len(ranked) - len(victims)
|
|
items.append(Choice(title=_rung_row(label, kept, len(victims), label_w), value=value))
|
|
held = f" · {protected} protected" if protected else ""
|
|
return SelectScreen(
|
|
f"Archive 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], locks: list[ScoredContact] | None = None) -> list:
|
|
"""The preview's rows: a pinned column header, one line for each victim, then Apply/Back.
|
|
|
|
Each contact row is ``deletable``, so ``Delete`` removes it from the sweep. Each row also
|
|
keeps its type mark and its **name** out of the ``←→`` scroll (``hscroll_from``). A
|
|
narrow terminal cuts the evidence lanes. If the name moved away when the user scrolls to
|
|
read those lanes, the row would lose the one thing that tells which contact it is.
|
|
|
|
This function measures the lanes across all the victims. That is also why a spared row
|
|
makes this function build all the rows again, instead of a removal of one row: the
|
|
value that left may have set a width.
|
|
|
|
A locked contact has no row: its row left the list when the user locked it, the same
|
|
as a spared row. The Apply row counts the staged locks, and the Back row tells that
|
|
they go too. When the user removed each victim and some locks are staged, an empty
|
|
state stands in place of the rows, and Apply writes only the locks.
|
|
"""
|
|
staged = len(locks or ())
|
|
if victims:
|
|
widths = _lane_widths(victims)
|
|
rows: list = [
|
|
Separator(lambda w: _preview_header(widths, w), pinned=True),
|
|
*(
|
|
Choice(
|
|
title=_victim_row(v, widths),
|
|
value=v,
|
|
deletable=True,
|
|
hscroll_from=_MARK_W + _NAME_W,
|
|
)
|
|
for v in victims
|
|
),
|
|
]
|
|
else:
|
|
rows = [Separator("no contacts left to archive", style="muted")]
|
|
if not staged:
|
|
apply = f"Archive {_count_desc(len(victims))}"
|
|
elif victims:
|
|
apply = f"Archive {_count_desc(len(victims))} and lock {staged}"
|
|
else:
|
|
apply = f"Lock {_count_desc(staged)}"
|
|
return [
|
|
*rows,
|
|
# The same shape that ``exit_rows`` draws, in the words of this flow. Apply has no
|
|
# keyboard key of its own, so it must have a visible partner row that tells the cost
|
|
# of the other way out. This is not ``exit_rows`` itself, because these rows are not
|
|
# staged changes to a set of values, and the pair must say what it archives.
|
|
Separator(" "),
|
|
Choice(title=Text.assemble(("✓ ", "ok"), apply), value=_APPLY),
|
|
Choice(
|
|
title=Text.assemble(
|
|
("✗ ", "err"), "Back — change nothing" if staged else "Back — keep them all"
|
|
),
|
|
value=_BACK,
|
|
),
|
|
]
|
|
|
|
|
|
def _has_full_key(value: object) -> bool:
|
|
"""Whether a preview row holds a contact with its full key, which a lock must have.
|
|
|
|
The store attaches a lock to the full key (refer to
|
|
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). The node page has the
|
|
same rule for its ``Lock contact`` row.
|
|
"""
|
|
if not isinstance(value, ScoredContact):
|
|
return False
|
|
return len((value.contact.public_key or "").lower().removeprefix("0x")) == 64
|
|
|
|
|
|
class _PreviewScreen(SelectScreen):
|
|
"""The preview list, with a lock key beside the spare key.
|
|
|
|
``Shift+Delete`` on a contact row resolves the list with a
|
|
:class:`~meshterm.ui.tui.select.KeyRequest` for :data:`_LOCK`. The caller then stages the
|
|
lock and removes the row (refer to :func:`_preview`). The key acts only when the caller
|
|
can store a lock (``can_lock``), and only on a contact with a full key.
|
|
|
|
Two surfaces show the key. On a handheld, the ``Delete`` chip on F3 gets ``Lock`` as its
|
|
Shift half. On the desktop, the footer gets the ``⇧Del lock`` atom after ``Del keep``,
|
|
but only when the full hint stays in 72 cells. No other atom leaves the hint
|
|
to make space for it.
|
|
|
|
A contact row also says ``Enter open`` instead of ``Enter select``, because Enter on it
|
|
opens the node page. The Apply and Back rows keep ``Enter select``.
|
|
"""
|
|
|
|
def __init__(self, *args: Any, can_lock: bool, **kwargs: Any) -> None:
|
|
self._can_lock = can_lock
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def _locks_here(self) -> bool:
|
|
"""Whether the lock key acts on the highlighted row now."""
|
|
current = self._current_choice()
|
|
return (
|
|
self._can_lock
|
|
and current is not None
|
|
and current.deletable
|
|
and _has_full_key(current.value)
|
|
)
|
|
|
|
def handle(self, action: str, data: str = "") -> None:
|
|
"""Resolve ``Shift+Delete`` as a lock request. The list handles all other actions."""
|
|
if action == "shift_delete":
|
|
current = self._current_choice()
|
|
if current is not None and self._locks_here():
|
|
self.resolve(KeyRequest(_LOCK, current.value))
|
|
return
|
|
super().handle(action, data)
|
|
|
|
@property
|
|
def footer_hint(self) -> str:
|
|
"""The hint of the list, with ``Enter open`` and, if it fits, ``⇧Del lock`` on a contact."""
|
|
hint = super().footer_hint
|
|
current = self._current_choice()
|
|
if current is None or not current.deletable:
|
|
return hint
|
|
hint = hint.replace("Enter select", "Enter open")
|
|
if self._locks_here():
|
|
hint = splice_if_room(hint, _LOCK_HINT)
|
|
return hint
|
|
|
|
@property
|
|
def picocalc_lyra_lane(self):
|
|
"""The lane of the list, with ``Lock`` behind the ``Delete`` chip on F3."""
|
|
lane = list(super().picocalc_lyra_lane)
|
|
if self._can_lock and lane[2] is not None:
|
|
lane[2] = replace(
|
|
lane[2],
|
|
opp_label="Lock",
|
|
opp_action="shift_delete",
|
|
opp_enabled=self._locks_here(),
|
|
)
|
|
return lane
|
|
|
|
|
|
def _preview_title(victims: list[ScoredContact]) -> str:
|
|
"""The title of the preview. It counts the victims, so it changes with them."""
|
|
return f"Archive contacts — {_count_desc(len(victims))} to archive"
|
|
|
|
|
|
def _preview_screen(
|
|
victims: list[ScoredContact], locks: list[ScoredContact] | None = None
|
|
) -> _PreviewScreen:
|
|
"""The preview screen for a victim list, with a size and words that agree with its rows.
|
|
|
|
``locks`` is the list of staged locks, or ``None`` when the caller cannot store a lock.
|
|
Then the screen has no lock key.
|
|
"""
|
|
return _PreviewScreen(
|
|
_preview_title(victims),
|
|
_preview_items(victims, locks),
|
|
prompt="Archived contacts leave the device but stay here, with their history.",
|
|
# The base hint does not include the scroll atom or the keep atom. The select list
|
|
# adds each atom only when its keyboard key can act: ``←→`` only while the
|
|
# highlighted row is too wide, and ``Del keep`` only on a row that the user can
|
|
# spare. All the footer obeys this same rule ("advertise a key only where it
|
|
# acts"). The rule also keeps all three atoms in 72 cells at the same time.
|
|
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
|
|
delete_hint="Del keep",
|
|
default=_APPLY,
|
|
hscroll=True,
|
|
hscroll_hint="←→ scroll",
|
|
can_lock=locks is not None,
|
|
)
|
|
|
|
|
|
async def _preview(
|
|
ctx: AppContext, victims: list[ScoredContact], locks: list[ScoredContact] | None = None
|
|
) -> bool:
|
|
"""Show exactly which contacts will go, weakest first. Return whether the user agreed.
|
|
|
|
This is the audit step, and the reason why it is safe to offer the sweep. If MeshTerm
|
|
archives several dozen contacts because of a number that nobody can check, the user must
|
|
examine that operation one time first. The list has a filter, like all other lists. A
|
|
user who wants to know whether one specific node is in it must be able to type its name.
|
|
|
|
The list is also **editable**. ``Delete`` removes a contact from the sweep. The
|
|
alternative was to go back and select a less aggressive rung. That spares the one contact
|
|
that you recognized, but it also spares forty contacts that were not important to you.
|
|
The list of rows is data here, so it refreshes through
|
|
:meth:`~meshterm.ui.tui.select.SelectScreen.replace_items`: the highlight follows its
|
|
value, and the typed filter stays. The screen is not built again, because that would put
|
|
the user back at the top of a list that they were halfway down.
|
|
|
|
``Shift+Delete`` removes a contact in the same way, and it also stages a lock on it. The
|
|
lock is staged, not written, because the user decides on Apply (JP, 2026-10-19). Thus
|
|
Esc and the Back row discard the locks, and they ask first, as an editor with staged
|
|
values does (:func:`~meshterm.ui.menus.confirm_discard`). A spared row is not a staged
|
|
change: Esc discarded it before the lock existed, and it does so now.
|
|
|
|
Args:
|
|
ctx: The shared application context.
|
|
victims: The candidates, weakest first. This function **changes** the list when the
|
|
user removes rows, so the list of the caller is the list that the sweep archives.
|
|
locks: The staged locks. This function adds to the list of the caller in the same
|
|
way. ``None`` when the caller cannot store a lock: then the preview has no lock
|
|
key.
|
|
|
|
Returns:
|
|
Whether the user selected Apply for the contacts and the locks that remain after the
|
|
edits.
|
|
"""
|
|
from .node_detail_screen import open_node_detail
|
|
|
|
screen = _preview_screen(victims, locks)
|
|
# A hub: it owns a loop, so it is pushed one time and stays for the full visit. That is
|
|
# why a spared row can refresh the rows below the user's highlight. It is also why a
|
|
# detail page can nest above this screen, and this screen keeps 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:
|
|
if locks and not await confirm_discard(ctx, len(locks), verb="applying"):
|
|
continue # continue to edit: the same list, at the same place in it
|
|
return False
|
|
if chosen == _APPLY:
|
|
return bool(victims or locks)
|
|
if isinstance(chosen, DeleteRequest | KeyRequest):
|
|
removed = chosen.value
|
|
if removed in victims:
|
|
victims.remove(removed)
|
|
if isinstance(chosen, KeyRequest) and locks is not None and removed not in locks:
|
|
locks.append(removed)
|
|
if not victims and not locks:
|
|
# The user spared all the candidates. Thus there is no sweep left to
|
|
# preview. An empty list with an "Archive no contacts" row would be a
|
|
# screen that asks the user to confirm nothing.
|
|
return False
|
|
screen.replace_items(_preview_items(victims, locks), title=_preview_title(victims))
|
|
continue
|
|
# Each other row is a contact. Enter opens its node detail page, so a user who
|
|
# does not recognize a name can examine the node before MeshTerm archives it. The
|
|
# page opens with ``manage=False``, because this screen is already the place where
|
|
# the user decides what to archive. A second archive or delete of one contact,
|
|
# from inside the list of candidates, would give two answers to one question. The
|
|
# preview stays pushed below, so Esc on the page goes back to the row that opened
|
|
# it.
|
|
await open_node_detail(ctx, chosen.contact, manage=False)
|
|
|
|
|
|
def _count_desc(n: int) -> str:
|
|
"""``no contacts`` / ``1 contact`` / ``n contacts``: the phrase that the sweep counts with."""
|
|
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],
|
|
locks: list[ScoredContact] | None = None,
|
|
) -> int:
|
|
"""Confirm, then remove each victim from the device and archive it here. Return the count.
|
|
|
|
The confirm is **amber, and the user types nothing**. A bulk deletion has a red typed
|
|
gate. The two tiers are for two different costs, and this cost can be recovered: each
|
|
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 use the strongest warning of
|
|
the app on its most reversible bulk action. Then a warning has no meaning.
|
|
|
|
The removal is one command for each contact, local to the companion, with no LoRa
|
|
traffic. Thus it runs from start to end under the progress dialog, and it is not paced.
|
|
The store write comes after each successful removal, not in one batch at the end. Thus,
|
|
if the sweep is interrupted, the state stays consistent: each contact that is off the
|
|
device is stored as archived.
|
|
|
|
The staged ``locks`` are written after the confirm and before the removals. A lock is a
|
|
mark of MeshTerm, in the contact store, and nothing on the device. Thus it cannot fail
|
|
as a removal can, and it does not wait for one. When only locks are staged, there is
|
|
no confirm: a lock archives nothing, and the node page reverses it.
|
|
"""
|
|
locks = locks or []
|
|
if victims:
|
|
also = (
|
|
f" MeshTerm also locks {_count_desc(len(locks))}, so that no sweep takes them."
|
|
if locks
|
|
else ""
|
|
)
|
|
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 "
|
|
f"restored.{also}",
|
|
[("Cancel", False), ("Archive", True)],
|
|
title="Archive contacts",
|
|
default=1,
|
|
danger=True,
|
|
):
|
|
return 0
|
|
|
|
dev_pub = (self_key or "").lower().removeprefix("0x")
|
|
store = ctx.contact_store
|
|
locked = 0
|
|
if store is not None and dev_pub:
|
|
for scored in locks:
|
|
store.set_locked(dev_pub, scored.contact, True)
|
|
locked += 1
|
|
|
|
archived = failed = 0
|
|
if victims:
|
|
device = await ctx.device()
|
|
stamp = int(time.time())
|
|
with ctx.ui.progress("Archive 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:
|
|
# The contact is already absent from the device. Thus the work of the
|
|
# sweep on the device is done, and the archive here is what removes the
|
|
# row. This is not a failure.
|
|
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
|
|
except Exception as exc: # noqa: BLE001 - one bad removal must not stop the sweep
|
|
failed += 1
|
|
ctx.log.debug("archive: 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()
|
|
|
|
and_locked = f" and locked {locked}" if locked else ""
|
|
if not victims:
|
|
outcome = Text(f"✓ locked {_count_desc(locked)}", style="ok")
|
|
elif archived and not failed:
|
|
outcome = Text(f"✓ archived {_count_desc(archived)}{and_locked}", style="ok")
|
|
elif archived:
|
|
outcome = Text(
|
|
f"archived {_count_desc(archived)}{and_locked}; {failed} could not be removed",
|
|
style="warn",
|
|
)
|
|
else:
|
|
locked_note = f"; locked {_count_desc(locked)}" if locked else ""
|
|
outcome = Text(f"no contacts could be archived{locked_note}", style="err")
|
|
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
|
|
return archived
|