Purge contacts is Archive contacts, because it archives them
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0b1ec94cdb
commit
8076ac1081
17 files changed
+109
-103
No files matched your search
@@ -17,6 +17,11 @@ allowed to change behaviour, not just add to it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Purge contacts is Archive contacts**, because archiving is what it does: every contact
|
||||
it takes off the device stays in MeshTerm and can be restored. The row, its screens, its
|
||||
confirm button and its progress bar all say Archive, and the row wears the 💾 that
|
||||
archiving one contact already wore, not the red 🗑 a deletion gets.
|
||||
|
||||
- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two
|
||||
console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the
|
||||
`xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
|
||||
arrive — so a radio left running long enough ends up holding mostly nodes heard once, in
|
||||
passing, from four hops away, while there is no room left to *discover* anyone new. The
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.purge_screen.purge_contacts`) is how
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.sweep_screen.archive_contacts`) is how
|
||||
that table gets its headroom back, and this module is the judgement it runs on: one score
|
||||
per contact, so the sweep can take the weakest and leave the ones you would actually miss.
|
||||
|
||||
@@ -18,7 +18,7 @@ must not have.
|
||||
contacts on a real mesh advertise no location, plenty are never overheard as a relayed
|
||||
packet so have no hop count, and a channel poster whose name matches no contact cannot be
|
||||
attributed at all. If "we don't know" resolved to ``0.0``, every one of those would sink to
|
||||
the bottom *together*, and the sweep would purge by how much metadata a node happens to
|
||||
the bottom *together*, and the sweep would archive by how much metadata a node happens to
|
||||
broadcast rather than by how much it is worth. So a term that cannot be computed returns
|
||||
``None`` and is filled with the **population median** of the contacts that could compute it
|
||||
(:func:`_fill_unknowns`) — the contact lands exactly where an average peer would on that
|
||||
@@ -45,7 +45,7 @@ but they are never swept.
|
||||
came from. What this module hands a screen instead is a *rank* — the list comes back ordered,
|
||||
and :attr:`~ScoredContact.percentile` places one contact in the field without a legend and
|
||||
without going stale when the weights change (see :func:`percentile_rank`). What a screen draws
|
||||
beside a contact is neither: the purge preview shows the :class:`ContactSignals` themselves, a
|
||||
beside a contact is neither: the archive preview shows the :class:`ContactSignals` themselves, a
|
||||
lane per measured kind, because the thing a reader can check against what they know is the
|
||||
evidence, not this module's reading of it.
|
||||
"""
|
||||
@@ -69,13 +69,13 @@ PROTECT_WATCHED = "watched"
|
||||
#: undone by a score. (Inbound-only traffic does *not* protect — anyone can message you.)
|
||||
PROTECT_MESSAGED = "messaged"
|
||||
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Purging it
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Archiving it
|
||||
#: costs the login, which is a far larger loss than a contact slot is a gain.
|
||||
PROTECT_ADMIN = "admin"
|
||||
|
||||
#: Protection reason: MeshTerm has never heard this contact, so every evidence term is
|
||||
#: empty for a reason that says nothing about the node. It came from the device's own table
|
||||
#: (or was added by hand) before this history began; sweeping it would be purging by how
|
||||
#: (or was added by hand) before this history began; sweeping it would be archiving by how
|
||||
#: long MeshTerm has been running rather than by anything the contact did.
|
||||
PROTECT_UNOBSERVED = "unobserved"
|
||||
|
||||
@@ -214,9 +214,9 @@ class ScoredContact:
|
||||
protection: Why this contact can never be swept, or ``None`` if it can. One of the
|
||||
``PROTECT_*`` constants.
|
||||
|
||||
The **signals** are what a screen shows, not the score or its terms: the purge preview
|
||||
The **signals** are what a screen shows, not the score or its terms: the archive preview
|
||||
draws one lane per measured kind straight off :attr:`signals` (see
|
||||
:mod:`~meshterm.ui.purge_screen`), so a reader auditing a sweep reads the evidence in
|
||||
:mod:`~meshterm.ui.sweep_screen`), so a reader auditing a sweep reads the evidence in
|
||||
the same units it was gathered in rather than a phrase this module chose for them.
|
||||
"""
|
||||
|
||||
@@ -541,7 +541,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
|
||||
protections against the target instead would mean starring a node quietly deepened the
|
||||
next sweep, which is the opposite of what a star is for. The picker's rungs therefore
|
||||
report their own real removal counts rather than arithmetic on the table size (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`).
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`).
|
||||
|
||||
Args:
|
||||
ranked: The full ranking from :func:`rank_contacts`, strongest first.
|
||||
|
||||
@@ -207,7 +207,7 @@ class DeviceState:
|
||||
|
||||
So the three are merged by taking the latest, here — once, at the one point every
|
||||
screen fetches contacts through, so heard lanes, recency sorts, silence alerts and
|
||||
the purge ladder all see the same honest value. Taking the *latest* rather than
|
||||
the archive ladder all see the same honest value. Taking the *latest* rather than
|
||||
preferring either side means a device time still stands whenever the firmware caught
|
||||
an advert we did not record (monitoring off, app not running); we only override it
|
||||
with proof of a later reception. A contact we have truly never heard stays ``None``
|
||||
|
||||
@@ -135,7 +135,7 @@ class ChatTool(Tool):
|
||||
# scroll nor the erase atom is written into the base: the select list splices
|
||||
# each in exactly when its key would act — ←→ only while the highlighted row
|
||||
# overflows, Del only on a thread that has history to lose — and the three of
|
||||
# them together are what has to stay inside 72 cells (the purge preview's list
|
||||
# them together are what has to stay inside 72 cells (the archive preview's list
|
||||
# is the same arithmetic). `Del erase` is that budget's doing: the row already
|
||||
# names the conversation, so the atom spends its cells on the verb, and `erase`
|
||||
# is the word the app uses elsewhere for taking data away.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Archived contacts list: what the sweep took off the device, and when.
|
||||
|
||||
A purge (see :mod:`~meshterm.ui.purge_screen`) removes a contact from the *device* — the
|
||||
scarce resource — and keeps it here in full. This screen is where those contacts live: the
|
||||
same sortable, filterable lane layout as the Contacts list, in its own lane set
|
||||
An archive sweep (see :mod:`~meshterm.ui.sweep_screen`) removes a contact from the *device*
|
||||
— the scarce resource — and keeps it here in full. This screen is where those contacts live:
|
||||
the same sortable, filterable lane layout as the Contacts list, in its own lane set
|
||||
(``NAME · ARCHIVED · KEY``, :data:`~meshterm.ui.contactlist.ARCHIVED_LANES`), with its own
|
||||
sort ring over exactly those three columns.
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ class ContactListScreen(SelectScreen):
|
||||
|
||||
Built on ``super()``'s lane rather than the bare default, so a contact list that
|
||||
carries section headings (the Time Machine's whole-mesh lead, the Contacts screen's
|
||||
purge tail) keeps the section jumps the select list promotes onto F1/F2.
|
||||
archive tail) keeps the section jumps the select list promotes onto F1/F2.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
@@ -536,7 +536,7 @@ class ContactListScreen(SelectScreen):
|
||||
"""The lead rows, the sort-aware column header, the contact lanes, then any tail rows.
|
||||
|
||||
Own-node rows lead the lanes and stay first whatever the sort: only the block
|
||||
below them reorders (see :func:`_ordered`). Tail rows (a purge action, the exit
|
||||
below them reorders (see :func:`_ordered`). Tail rows (an archive action, the exit
|
||||
group) close the list, past every contact whatever the sort.
|
||||
"""
|
||||
items: list = list(self._lead)
|
||||
|
||||
@@ -11,15 +11,15 @@ and backing out of that page returns to the list right where it stood. The one-s
|
||||
(``meshterm contacts --sort …``) still renders the static
|
||||
:func:`~meshterm.ui.widgets.contacts_table`; only the menu gets the live list.
|
||||
|
||||
Below the contacts sits the list's own maintenance action — **Purge contacts** — the bulk
|
||||
Below the contacts sits the list's own maintenance action — **Archive contacts** — the bulk
|
||||
counterpart, which exists because a companion's contact table is finite and a busy mesh
|
||||
fills it with nodes heard once in passing until there is no room left to discover anyone
|
||||
new. It ranks the whole table (see :mod:`~meshterm.core.contact_score`) and archives the
|
||||
weakest off the device while MeshTerm keeps them; the flow, its ladder and its preview live
|
||||
in :mod:`~meshterm.ui.purge_screen`. Our own node, pinned above the list, is never a
|
||||
in :mod:`~meshterm.ui.sweep_screen`. Our own node, pinned above the list, is never a
|
||||
candidate — it isn't in the device's contact table.
|
||||
|
||||
What a sweep took is never hidden: **View archived contacts** sits directly under the purge
|
||||
What a sweep took is never hidden: **View archived contacts** sits directly under the archive action
|
||||
action and opens the archived list (see :mod:`~meshterm.ui.archived_screen`) — its own
|
||||
screen, in the same lane layout, sorted on its own ``NAME · ARCHIVED · KEY`` columns. It
|
||||
earns a screen rather than a section at the foot of this one because it is a different list
|
||||
@@ -42,7 +42,7 @@ from typing import TYPE_CHECKING
|
||||
from .archived_screen import open_archived
|
||||
from .contactlist import ContactListScreen, ContactRow
|
||||
from .menus import icon_lane, marked_label
|
||||
from .purge_screen import purge_contacts
|
||||
from .sweep_screen import archive_contacts
|
||||
from .tui import Choice, Separator
|
||||
from .tui.screen import CANCEL
|
||||
from .widgets import ContactsSort, contact_packets
|
||||
@@ -57,13 +57,13 @@ YOU = ("you",)
|
||||
|
||||
#: The contact-list tail sentinels (distinct from any :class:`~meshterm.core.models.Contact`
|
||||
#: and from :data:`YOU`): the sweep, and the way in to what it has already taken.
|
||||
_PURGE = ("purge",)
|
||||
_ARCHIVE = ("archive",)
|
||||
_ARCHIVED = ("archived",)
|
||||
|
||||
#: Every mark the tail's maintenance rows lead with — the set the icon column is measured
|
||||
#: over (see :func:`~meshterm.ui.menus.icon_lane`). Declared rather than inferred so the
|
||||
#: column is one width for the whole tail whichever of the two rows a given visit draws.
|
||||
_TAIL_ICONS = ("🗑", "📂")
|
||||
_TAIL_ICONS = ("💾", "📂")
|
||||
|
||||
|
||||
class ContactsScreen(ContactListScreen):
|
||||
@@ -116,20 +116,21 @@ class ContactsScreen(ContactListScreen):
|
||||
)
|
||||
)
|
||||
# The maintenance actions close the list, past every contact whatever the sort: a
|
||||
# blank spacer, then the err-tinted sweep (a `…` — it opens further prompts), then
|
||||
# the way in to what the sweep has already taken. The two sit together because they
|
||||
# are the two halves of one idea, and the archived row is *under* the purge for the
|
||||
# same reason: it is where the purge's output went.
|
||||
# One icon column for both rows: 🗑 draws a single cell where 📂 draws two, so an
|
||||
# unpadded mark would start the purge row's label a column left of the other's.
|
||||
# blank spacer, then the sweep (a `…` — it opens further prompts), then the way in
|
||||
# to what the sweep has already taken. The two sit together because they are the
|
||||
# two halves of one idea, and the archived row is *under* the sweep for the same
|
||||
# reason: it is where the sweep's output went. The sweep wears the single-contact
|
||||
# Archive action's 💾 and no red: it archives, and red is kept for data loss.
|
||||
# One icon column for both rows, measured rather than assumed, so a mark narrower
|
||||
# than its neighbour can't start its row's label a column left of the other's.
|
||||
lane = icon_lane(_TAIL_ICONS)
|
||||
tail: list = []
|
||||
if contacts:
|
||||
tail = [
|
||||
Separator(" "),
|
||||
Choice(
|
||||
title=marked_label("🗑", "Purge contacts…", "err", lane=lane),
|
||||
value=_PURGE,
|
||||
title=marked_label("💾", "Archive contacts…", "", lane=lane),
|
||||
value=_ARCHIVE,
|
||||
),
|
||||
]
|
||||
if archived:
|
||||
@@ -160,7 +161,7 @@ async def open_contacts(
|
||||
"""Open the interactive contacts list; Enter opens Node detail, Esc leaves.
|
||||
|
||||
The list stays pushed for the whole visit, so whichever node's detail page Enter commits
|
||||
(or the purge flow, or the archived list, that the tail actions open) nests *above* it
|
||||
(or the archive flow, or the archived list, that the tail actions open) nests *above* it
|
||||
and Esc from there is one pop back onto the row it was opened from — same sort, same
|
||||
filter, same scroll. Anything that changes which contacts the device holds — a sweep
|
||||
here, a single archive, restore or delete on a detail page, or a restore made from
|
||||
@@ -208,13 +209,13 @@ async def open_contacts(
|
||||
chosen = await visit.result()
|
||||
if chosen is CANCEL or chosen is None: # Esc
|
||||
return
|
||||
if chosen == _PURGE:
|
||||
# The purge ladder, its preview and its confirm all float over the list,
|
||||
if chosen == _ARCHIVE:
|
||||
# The archive ladder, its preview and its confirm all float over the list,
|
||||
# which is already the backdrop. A sweep that archived anything
|
||||
# invalidates the contacts cache, so the list is re-read and rebuilt —
|
||||
# the one deliberate reset in this screen's life, because the rows it was
|
||||
# keeping a place in are genuinely gone from the device.
|
||||
if await purge_contacts(ctx, self_key):
|
||||
if await archive_contacts(ctx, self_key):
|
||||
rebuild = True
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -25,7 +25,7 @@ the node itself, in full:
|
||||
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
|
||||
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
|
||||
offered whenever the node's full key is known — and, last, ``Remove contact``: the
|
||||
single-contact counterpart to the Contacts list's bulk purge (see
|
||||
single-contact counterpart to the Contacts list's bulk archive (see
|
||||
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
|
||||
table behind a red confirm. It is the one thing on the page that changes anything, so
|
||||
it sits at the foot of the actions, and committing it closes the page — the contact it
|
||||
@@ -1090,7 +1090,7 @@ async def open_node_detail(
|
||||
can be archived or deleted, an archived one restored or deleted.
|
||||
|
||||
``manage`` is how a caller says the page is being opened to *look*, not to act. The
|
||||
purge preview passes ``False``: a screen whose whole job is choosing what to archive
|
||||
archive preview passes ``False``: a screen whose whole job is choosing what to archive
|
||||
should not also hand out a second, singular way to archive — or a delete — from inside
|
||||
its own candidate list. The rule is that management verbs belong to the list a contact
|
||||
actually lives in, never to a page opened out of a list that is about to act on it
|
||||
@@ -1451,7 +1451,7 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
|
||||
"""Confirm and archive one contact off the device; ``True`` once it is gone from the radio.
|
||||
|
||||
The single-contact counterpart to the bulk sweep (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`), doing exactly what the sweep does to
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`), doing exactly what the sweep does to
|
||||
each of its victims: remove from the device, then record it in the cross-session store
|
||||
with an archive stamp so nothing is actually lost.
|
||||
|
||||
@@ -1515,7 +1515,7 @@ async def _restore_archived(ctx: AppContext, contact: Contact, self_key: str, la
|
||||
"""Write an archived contact back onto the device; ``True`` once it is live again.
|
||||
|
||||
The inverse of the Contacts sweep, one contact at a time (see
|
||||
:mod:`~meshterm.ui.purge_screen`). The device write comes **first** and the store's
|
||||
:mod:`~meshterm.ui.sweep_screen`). The device write comes **first** and the store's
|
||||
archive mark is cleared only once it succeeded — a failed write must never leave a
|
||||
contact listed as live on a radio that doesn't hold it, which would be a row you cannot
|
||||
message and cannot restore.
|
||||
@@ -1560,7 +1560,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
|
||||
"""Confirm and drop one contact from the device; ``True`` once it is gone.
|
||||
|
||||
The single-contact counterpart to the Contacts list's bulk sweep (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`) — but a *deletion* where that one
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`) — but a *deletion* where that one
|
||||
archives: this is the way to make MeshTerm forget a node entirely, so it removes the
|
||||
contact in both places, because the list a screen sees is the *union* of the two: the device's
|
||||
own contact table, and the contacts MeshTerm remembers for that device (see
|
||||
@@ -1641,7 +1641,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
|
||||
ctx.contact_store.forget(dev_pub, contact.public_key)
|
||||
# The device's table just changed under the session cache; the list we return to re-reads.
|
||||
ctx.devstate.invalidate_contacts()
|
||||
# No success notice: unlike the purge — whose outcome is a count nobody could predict —
|
||||
# No success notice: unlike the archive sweep — whose outcome is a count nobody could predict —
|
||||
# this one is self-evident. The page closes on the contact it detailed and the list
|
||||
# behind it comes back without the row, which says it better than a dialog to dismiss.
|
||||
return True
|
||||
|
||||
@@ -25,7 +25,7 @@ the pushed Contacts list:
|
||||
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
|
||||
3. **Confirm.** An amber Cancel/Archive 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.
|
||||
@@ -97,7 +97,7 @@ _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.
|
||||
#: yet. The same ladder the age-only sweep 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),
|
||||
@@ -353,12 +353,12 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
|
||||
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)
|
||||
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 purge_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
async def archive_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
|
||||
@@ -488,7 +488,7 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
|
||||
items.append(Choice(title=_rung_row(label, kept, len(victims), label_w), value=value))
|
||||
held = f" · {protected} protected" if protected else ""
|
||||
return SelectScreen(
|
||||
f"Purge contacts — {len(ranked)} known{held}",
|
||||
f"Archive contacts — {len(ranked)} known{held}",
|
||||
items,
|
||||
prompt=(
|
||||
"Archives the weakest off the device, keeping them here. Ranked on messages, "
|
||||
@@ -536,7 +536,7 @@ def _preview_screen(victims: list[ScoredContact]):
|
||||
from .tui import SelectScreen
|
||||
|
||||
return SelectScreen(
|
||||
f"Purge contacts — {_count_desc(len(victims))} to archive",
|
||||
f"Archive 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
|
||||
@@ -602,7 +602,7 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
|
||||
# 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",
|
||||
title=f"Archive contacts — {_count_desc(len(victims))} to archive",
|
||||
)
|
||||
continue
|
||||
# Any other row is a contact: Enter opens its node detail page, so a reader who
|
||||
@@ -640,8 +640,8 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
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",
|
||||
[("Cancel", False), ("Archive", True)],
|
||||
title="Archive contacts",
|
||||
default=1,
|
||||
danger=True,
|
||||
):
|
||||
@@ -652,7 +652,7 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
store = ctx.contact_store
|
||||
stamp = int(time.time())
|
||||
archived = failed = 0
|
||||
with ctx.ui.progress("Purge contacts") as progress:
|
||||
with ctx.ui.progress("Archive contacts") as progress:
|
||||
task = progress.add_task("Archiving", total=len(victims))
|
||||
for scored in victims:
|
||||
contact = scored.contact
|
||||
@@ -661,10 +661,10 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
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)
|
||||
ctx.log.debug("archive: %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)
|
||||
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
|
||||
continue
|
||||
finally:
|
||||
progress.advance(task)
|
||||
@@ -681,5 +681,5 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
)
|
||||
else:
|
||||
outcome = Text("no contacts could be archived", style="err")
|
||||
await ctx.ui.session.message_dialog(outcome, title="Purge contacts")
|
||||
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
|
||||
return archived
|
||||
@@ -165,7 +165,7 @@ def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_wide() -> None:
|
||||
assert pinned.at(edge) == "│"
|
||||
|
||||
|
||||
def test_every_lane_of_a_purge_preview_row_lands_where_the_screen_measured_it() -> None:
|
||||
def test_every_lane_of_a_archive_preview_row_lands_where_the_screen_measured_it() -> None:
|
||||
"""The screen this started on: a swept contact whose name carries an emoji.
|
||||
|
||||
The name lane is already fitted in display cells (:func:`~meshterm.ui.menus.fit_cells`),
|
||||
@@ -174,7 +174,7 @@ def test_every_lane_of_a_purge_preview_row_lands_where_the_screen_measured_it()
|
||||
"""
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _NAME_W, _lane_widths, _victim_row
|
||||
from meshterm.ui.sweep_screen import _NAME_W, _lane_widths, _victim_row
|
||||
from meshterm.ui.tui.render import render_to_ansi
|
||||
|
||||
key = "ab" * 32
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_an_unknown_signal_scores_neutral_not_zero() -> None:
|
||||
location at all, plenty of nodes are never overheard as a relayed packet so carry no hop
|
||||
count, and a channel poster whose name matches no contact can't be attributed. If any of
|
||||
those resolved to zero, every unmeasurable contact would sink together and the sweep
|
||||
would be purging by how much metadata a node happens to broadcast.
|
||||
would be archiving by how much metadata a node happens to broadcast.
|
||||
"""
|
||||
# Two contacts identical in every measured way; one is simply unplaced and unrouted.
|
||||
ranked = _by_name(
|
||||
|
||||
@@ -238,7 +238,7 @@ async def test_devstate_merge_keeps_a_repeater_a_repeater(ctx) -> None:
|
||||
def test_an_archived_contact_is_remembered_but_kept_out_of_the_merge(tmp_path) -> None: # noqa: ANN001
|
||||
"""Archiving frees a device slot without losing the contact — and without undoing itself.
|
||||
|
||||
The sweep's whole shape (see :mod:`meshterm.ui.purge_screen`): the contact is deleted
|
||||
The sweep's whole shape (see :mod:`meshterm.ui.sweep_screen`): the contact is deleted
|
||||
from the *device*, which is the scarce resource, and kept here in full. The subtlety is
|
||||
the merge — this store's normal job is to union remembered contacts back into any list
|
||||
the device isn't reporting, which would put an archived contact straight back and make
|
||||
|
||||
+28
-28
@@ -311,7 +311,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
|
||||
import re
|
||||
|
||||
from meshterm.core.models import Contact, utcnow
|
||||
from meshterm.ui.contacts_screen import _PURGE, YOU, ContactsScreen
|
||||
from meshterm.ui.contacts_screen import _ARCHIVE, YOU, ContactsScreen
|
||||
from meshterm.ui.tui.screen import CANCEL
|
||||
|
||||
contacts = [
|
||||
@@ -333,8 +333,8 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
|
||||
assert f"{7:>5}" in body # Alice's overheard packets, right-aligned in its lane
|
||||
assert "never" in body # Bob has no last_seen
|
||||
# Each contact row carries the Contact itself, so Enter hands the whole record on; the
|
||||
# tail closes the list with the purge action (there is no exit row — Esc leaves).
|
||||
assert choices[-1].value == _PURGE
|
||||
# tail closes the list with the archive action (there is no exit row — Esc leaves).
|
||||
assert choices[-1].value == _ARCHIVE
|
||||
assert all(isinstance(c.value, Contact) for c in choices[1:-1])
|
||||
|
||||
# Plain arrows move the highlight without touching the sort.
|
||||
@@ -354,7 +354,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
|
||||
assert resolved == [highlighted, CANCEL]
|
||||
|
||||
|
||||
def test_purge_age_rungs_split_stale_from_never_heard() -> None:
|
||||
def test_archive_age_rungs_split_stale_from_never_heard() -> None:
|
||||
"""An age rung takes only contacts once heard and since gone quiet; never-heard has its own.
|
||||
|
||||
The ladder's second section is the plain predictable operation a ranking cannot express,
|
||||
@@ -364,7 +364,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
|
||||
"""
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _DAY, _NEVER, victims_for
|
||||
from meshterm.ui.sweep_screen import _DAY, _NEVER, victims_for
|
||||
|
||||
def scored(name: str, days, score: float) -> ScoredContact: # noqa: ANN001
|
||||
contact = Contact(name=name, public_key=f"{ord(name[0]):02x}" * 32)
|
||||
@@ -396,7 +396,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
|
||||
assert names(("age", 7 * _DAY)) == ["Ancient", "Old"]
|
||||
|
||||
|
||||
async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
|
||||
async def test_archive_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
|
||||
"""The whole sweep, end to end: ladder → preview → amber confirm → bar → outcome dialog.
|
||||
|
||||
Two things the busy overlay could not do (JP, 2026-08-10). It only ever said "working",
|
||||
@@ -414,8 +414,8 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals
|
||||
from meshterm.core.models import Contact, utcnow
|
||||
from meshterm.ui.purge_screen import purge_contacts
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import archive_contacts
|
||||
from meshterm.ui.tui.progress import ProgressScreen
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
@@ -459,7 +459,7 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
ui.dialog = _dialog # type: ignore[method-assign]
|
||||
ctx = SimpleNamespace(
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.purge"),
|
||||
log=logging.getLogger("test.archive"),
|
||||
repo=_Repo(),
|
||||
contact_store=SimpleNamespace(
|
||||
archive=lambda dev, contact, when: archived.append(contact.name)
|
||||
@@ -474,12 +474,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
device=lambda: _device(_Device()),
|
||||
)
|
||||
|
||||
task = asyncio.ensure_future(purge_contacts(ctx, "cc" * 32))
|
||||
task = asyncio.ensure_future(archive_contacts(ctx, "cc" * 32))
|
||||
|
||||
# Walk past the five standing rungs into the second section and take "not heard in 1
|
||||
# year", which catches all three — the standing rungs keep a share, so none of them
|
||||
# ever takes a whole table.
|
||||
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Purge contacts"))
|
||||
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Archive contacts"))
|
||||
body = _screen_text(ladder)
|
||||
# A rung is counted in columns, and what it *keeps* — the number that has to fit the
|
||||
# device — leads what it archives.
|
||||
@@ -503,12 +503,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
bar = await _step_until_screen(session, lambda s: isinstance(s, ProgressScreen))
|
||||
assert asked and asked[0]["danger"] is True
|
||||
assert "destructive" not in asked[0]
|
||||
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Purge"]
|
||||
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Archive"]
|
||||
assert "restored" in asked[0]["prompt"]
|
||||
|
||||
# The sweep runs under the progress dialog, which is the frontmost screen for its whole
|
||||
# lifetime — so the list underneath cannot be walked while it works.
|
||||
assert bar.title == "Purge contacts"
|
||||
assert bar.title == "Archive contacts"
|
||||
assert bar.render_body(60) # a real bar, with a real total to count down
|
||||
bar.handle("down") # every key is swallowed; nothing underneath moves
|
||||
|
||||
@@ -538,8 +538,8 @@ async def test_sparing_a_row_shrinks_the_sweep_without_leaving_the_preview() ->
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _preview
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import _preview
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
def scored(name: str, pct: int) -> ScoredContact:
|
||||
@@ -601,8 +601,8 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _preview
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import _preview
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
key = "aa" * 32
|
||||
@@ -623,7 +623,7 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_name", ["regular", "picocalc"])
|
||||
def test_the_purge_preview_keeps_its_cursor_in_its_box_walking_both_ways(
|
||||
def test_the_archive_preview_keeps_its_cursor_in_its_box_walking_both_ways(
|
||||
platform_name: str,
|
||||
) -> None:
|
||||
"""The preview's highlight stays inside its box however far the reader walks, and back.
|
||||
@@ -639,7 +639,7 @@ def test_the_purge_preview_keeps_its_cursor_in_its_box_walking_both_ways(
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.purge_screen import _preview_screen
|
||||
from meshterm.ui.sweep_screen import _preview_screen
|
||||
from meshterm.ui.tui.frame import compose_dialog
|
||||
|
||||
platform = {"regular": REGULAR, "picocalc": PICOCALC}[platform_name]
|
||||
@@ -963,19 +963,19 @@ async def test_the_list_rebuilds_when_the_detail_page_deletes_the_contact(
|
||||
await task
|
||||
|
||||
|
||||
def test_contacts_screen_tail_offers_purge_only_when_populated() -> None:
|
||||
"""A populated list closes with the purge action; an empty one has no tail at all."""
|
||||
def test_contacts_screen_tail_offers_archive_only_when_populated() -> None:
|
||||
"""A populated list closes with the archive action; an empty one has no tail at all."""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.contacts_screen import _PURGE, ContactsScreen
|
||||
from meshterm.ui.contacts_screen import _ARCHIVE, ContactsScreen
|
||||
|
||||
populated = ContactsScreen(
|
||||
"Us", "cc" * 32, [Contact(name="Alice", public_key="aa" * 32)], 1, {}, _contacts_sort()
|
||||
)
|
||||
assert populated._choices()[-1].value == _PURGE
|
||||
assert populated._choices()[-1].value == _ARCHIVE
|
||||
|
||||
empty = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort())
|
||||
values = [c.value for c in empty._choices()]
|
||||
assert _PURGE not in values # nothing to purge — no action row
|
||||
assert _ARCHIVE not in values # nothing to archive — no action row
|
||||
|
||||
|
||||
def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
@@ -986,7 +986,7 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
own screen is what keeps archiving from being something you lose track of.
|
||||
"""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.contacts_screen import _ARCHIVED, _PURGE, ContactsScreen
|
||||
from meshterm.ui.contacts_screen import _ARCHIVE, _ARCHIVED, ContactsScreen
|
||||
|
||||
contacts = [Contact(name="Alice", public_key="aa" * 32)]
|
||||
|
||||
@@ -995,16 +995,16 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
|
||||
some = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), archived=12)
|
||||
values = [c.value for c in some._choices()]
|
||||
# Under the purge, because it is where the purge's output went.
|
||||
assert values[-2:] == [_PURGE, _ARCHIVED]
|
||||
# Under the archive action, because it is where the sweep's output went.
|
||||
assert values[-2:] == [_ARCHIVE, _ARCHIVED]
|
||||
row = next(c for c in some._choices() if c.value == _ARCHIVED)
|
||||
assert "View archived contacts" in row.label and "12" in row.label
|
||||
|
||||
# It stands on its own for a device whose whole table has been swept: there is nothing
|
||||
# left to purge, but there is very much something to go and look at.
|
||||
# left to archive, but there is very much something to go and look at.
|
||||
swept = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort(), archived=3)
|
||||
values = [c.value for c in swept._choices()]
|
||||
assert _PURGE not in values and values[-1] == _ARCHIVED
|
||||
assert _ARCHIVE not in values and values[-1] == _ARCHIVED
|
||||
|
||||
|
||||
async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> None:
|
||||
@@ -1423,7 +1423,7 @@ async def _channels(device: MockDevice) -> list[dict]:
|
||||
async def test_the_preview_opens_node_pages_with_no_management_verbs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Enter on a purge candidate opens its page to *look* at, not to act on.
|
||||
"""Enter on a archive candidate opens its page to *look* at, not to act on.
|
||||
|
||||
The rule (JP, 2026-09-01): management verbs belong to the list a contact actually lives
|
||||
in, never to a page opened out of a list that is about to act on it wholesale. A preview
|
||||
@@ -1436,8 +1436,8 @@ async def test_the_preview_opens_node_pages_with_no_management_verbs(
|
||||
import meshterm.ui.node_detail_screen as nd
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _preview
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import _preview
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
opened: list[dict] = []
|
||||
|
||||
@@ -187,7 +187,7 @@ def test_a_direct_message_counts_as_hearing_its_sender() -> None:
|
||||
|
||||
A direct message never touches the firmware's ``last_advert`` and is stored as a message
|
||||
rather than an observation, so a node we actively chat with could read days stale (and be
|
||||
swept by the purge ladder's quiet rungs) while talking to us. The peer prefix the wire
|
||||
swept by the archive ladder's quiet rungs) while talking to us. The peer prefix the wire
|
||||
addressed need not match the contact table's width, so either may be the shorter.
|
||||
"""
|
||||
dev = FakeDevice()
|
||||
|
||||
+11
-11
@@ -190,7 +190,7 @@ def _contacts(cols: int, rows: int) -> Screen:
|
||||
return ContactsScreen("Homestead", "cc" * 32, contacts, 1, {"aa" * 6: 7}, sort, archived=12)
|
||||
|
||||
|
||||
def _purge_ranked(): # noqa: ANN201
|
||||
def _archive_ranked(): # noqa: ANN201
|
||||
"""A ranked contact table for the sweep's screens — scored by the real function.
|
||||
|
||||
Deliberately *not* hand-stamped percentiles. The whole point of the gallery is that a
|
||||
@@ -228,25 +228,25 @@ def _purge_ranked(): # noqa: ANN201
|
||||
return rank_contacts(contacts, signals)
|
||||
|
||||
|
||||
def _purge_victims(): # noqa: ANN201
|
||||
def _archive_victims(): # noqa: ANN201
|
||||
"""The sweep's victim list: the weakest half of the ranking, weakest first."""
|
||||
from meshterm.core.contact_score import sweep_candidates
|
||||
|
||||
ranked = _purge_ranked()
|
||||
ranked = _archive_ranked()
|
||||
return sweep_candidates(ranked, keep=2)
|
||||
|
||||
|
||||
def _purge_ladder(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.purge_screen import _target_screen
|
||||
def _archive_ladder(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.sweep_screen import _target_screen
|
||||
|
||||
ranked = _purge_ranked()
|
||||
ranked = _archive_ranked()
|
||||
return _target_screen(ranked, [r for r in ranked if not r.protected])
|
||||
|
||||
|
||||
def _purge_preview(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.purge_screen import _preview_screen
|
||||
def _archive_preview(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.sweep_screen import _preview_screen
|
||||
|
||||
return _preview_screen(_purge_victims())
|
||||
return _preview_screen(_archive_victims())
|
||||
|
||||
|
||||
def _archived(cols: int, rows: int) -> Screen:
|
||||
@@ -972,8 +972,8 @@ def _share_qr(cols: int, rows: int) -> Screen:
|
||||
_ENTRIES: list[_Entry] = [
|
||||
_Entry("dashboard", _dashboard),
|
||||
_Entry("contacts", _contacts),
|
||||
_Entry("purge_ladder", _purge_ladder),
|
||||
_Entry("purge_preview", _purge_preview),
|
||||
_Entry("archive_ladder", _archive_ladder),
|
||||
_Entry("archive_preview", _archive_preview),
|
||||
_Entry("archived", _archived),
|
||||
_Entry("node_detail", _node_detail),
|
||||
_Entry("node_detail_map", _node_detail_map),
|
||||
|
||||
+6
-6
@@ -251,12 +251,12 @@ def test_marked_label_lines_up_a_mixed_list_when_told_its_lane() -> None:
|
||||
|
||||
lane = icon_lane((_NARROW, _WIDE))
|
||||
rows = [
|
||||
marked_label(_NARROW, "Purge contacts…", "err", lane=lane),
|
||||
marked_label(_NARROW, "Archive contacts…", "err", lane=lane),
|
||||
marked_label(_WIDE, "View archived contacts", "", lane=lane),
|
||||
]
|
||||
starts = {
|
||||
cell_len(row.plain[: row.plain.index(word)])
|
||||
for row, word in zip(rows, ("Purge", "View"), strict=True)
|
||||
for row, word in zip(rows, ("Archive", "View"), strict=True)
|
||||
}
|
||||
assert starts == {lane + 1}
|
||||
|
||||
@@ -287,10 +287,10 @@ def test_align_icons_starts_every_word_in_the_same_cell() -> None:
|
||||
"""
|
||||
plain_row = "Reorder"
|
||||
labels = align_icons(
|
||||
[f"{_NARROW} Purge contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
|
||||
[f"{_NARROW} Archive contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
|
||||
)
|
||||
assert [_plain(label) for label in labels] == [
|
||||
f"{_NARROW} Purge contacts…",
|
||||
f"{_NARROW} Archive contacts…",
|
||||
f"{_WIDE} View archived",
|
||||
"Reorder",
|
||||
]
|
||||
@@ -337,8 +337,8 @@ def test_align_icons_pads_nothing_where_the_platform_draws_no_icons() -> None:
|
||||
|
||||
set_platform(PICOCALC)
|
||||
try:
|
||||
labels = align_icons([f"{_NARROW} Purge contacts…", f"{_WIDE} View archived"])
|
||||
assert [_plain(label) for label in labels] == ["Purge contacts…", "View archived"]
|
||||
labels = align_icons([f"{_NARROW} Archive contacts…", f"{_WIDE} View archived"])
|
||||
assert [_plain(label) for label in labels] == ["Archive contacts…", "View archived"]
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ def test_select_cursor_line_counts_a_prompt_once() -> None:
|
||||
The rows are laid out after the prompt's lines, so the index they are recorded at
|
||||
already counts them. Adding the prompt's height on top reported a line further down,
|
||||
and the frame kept *that* line in view: walking ↑ then carried the real highlight above
|
||||
the window's top edge (the purge preview, whose prompt sits over a long list).
|
||||
the window's top edge (the archive preview, whose prompt sits over a long list).
|
||||
"""
|
||||
items = [Choice(f"c{i}", i) for i in range(12)]
|
||||
screen = SelectScreen("pick", items, prompt="Read the list, then pick one.", default=3)
|
||||
|
||||
Reference in new issue
Block a user