Select contacts, and empty an address book

Raised on #174 as the other half of a migration -- import, notice
something is wrong, empty the book, correct the export, import again --
and tracked as #277.

The gap turned out to be wider than the ask. Contacts had no multi-select
at all: the only delete in the module was the cross on a single card's
pane, one card and one confirmation at a time. `destroyCards` has taken a
list and batched it against maxObjectsInSet since #218, and nothing in
the UI ever handed it more than one id. So "empty this address book" was
missing, and so was "delete these fourteen".

The list now has checkboxes, on hover the way the message list's are, and
always on a touchscreen where there is no hover to reveal them.
Shift-click takes the run between two rows. The search box gives way to a
selection bar rather than sitting beside it, because what the count
promises is what the search left on screen. A selection is cleared when
the book being shown changes, since carrying it across would leave a
count describing rows that are no longer there and a Delete aimed at
them.

Emptying a book is in the book's own menu, beside the import and export
that moved there in #226, and separate from Delete, which takes the book
with it. A default book cannot be deleted and can perfectly well be
emptied, which is most of the reason it is its own entry.

The part that is not a deletion, and the reason this is not one destroy
over everything in the book: a card filed in two books belongs to both,
and `ContactCard/set destroy` takes it away from both at once. Emptying
one book must not empty another, so a card with a second home is patched
out of this one and left alone. That is reported separately afterwards,
because it would otherwise look like contacts that refused to go.

`destroyCards` now answers with what the server confirmed rather than
throwing on the first refusal. A refusal that took half a selection with
it still deleted the other half, and an error saying only that it failed
sends somebody looking for contacts that are already gone. Both callers
report the count and the reason apart.

Emptying a shared book is deliberately not offered: the cards live in the
owner's account and this client has no path to write there.

One bug found by driving the built app rather than by any test, and worth
recording because of where it hid. The range a shift-click covers was
measured inside the `setPicked` updater -- which React runs when it gets
round to rendering, by which time the anchor ref has already been moved
to the row that *ended* the range. Every shift-click selected exactly one
row, and every store assertion still passed, because nothing was wrong
below the component. The anchor is read before the updater now, and the
contacts view has its first component tests: ten of them, six of which
fail if the measurement moves back inside.

Twelve new strings, in all nine catalogues, so nothing new falls back to
English.
This commit is contained in:
2026-09-04 12:08:53 -07:00
parent f201b09e90
commit cee107d948
16 changed files with 692 additions and 16 deletions
+14
View File
@@ -1322,6 +1322,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Dies kann nicht rückgängig gemacht werden.",
"Some could not be deleted: {error}": "Einige konnten nicht gelöscht werden: {error}",
"It was not deleted": "Der Kontakt wurde nicht gelöscht",
"Empty address book": "Dieses Adressbuch leeren",
"There is nothing in it to delete": "Es ist nichts darin zum Löschen",
"Empty “{name}”?": "„{name}“ leeren?",
"Delete them": "Alle löschen",
"Nothing was deleted": "Es wurde nichts gelöscht",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1373,5 +1382,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} Nachricht als gelesen markiert", other: "{n} Nachrichten als gelesen markiert" },
"in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" },
"Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "{n} Kontakt löschen?", other: "{n} Kontakte löschen?" },
"Deleted {n} contacts": { one: "{n} Kontakt gelöscht", other: "{n} Kontakte gelöscht" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} Kontakt wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "{n} Kontakte werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
"{n} were also in other address books and were only removed from this one": { one: "{n} Kontakt war auch in einem anderen Adressbuch und wurde nur aus diesem entfernt", other: "{n} Kontakte waren auch in anderen Adressbüchern und wurden nur aus diesem entfernt" },
},
};
+14
View File
@@ -1295,6 +1295,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Esto no se puede deshacer.",
"Some could not be deleted: {error}": "Algunos no se pudieron eliminar: {error}",
"It was not deleted": "No se ha eliminado",
"Empty address book": "Vaciar esta libreta de direcciones",
"There is nothing in it to delete": "No hay nada dentro que eliminar",
"Empty “{name}”?": "¿Vaciar «{name}»?",
"Delete them": "Eliminarlos",
"Nothing was deleted": "No se ha eliminado nada",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1346,5 +1355,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} mensaje marcado como leído", other: "{n} mensajes marcados como leídos" },
"in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" },
"Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "¿Eliminar {n} contacto?", other: "¿Eliminar {n} contactos?" },
"Deleted {n} contacts": { one: "Se ha eliminado {n} contacto", other: "Se han eliminado {n} contactos" },
"{n} contacts will be deleted. This cannot be undone.": { one: "Se eliminará {n} contacto. Esto no se puede deshacer.", other: "Se eliminarán {n} contactos. Esto no se puede deshacer." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contacto también estaba en otra libreta de direcciones y solo se ha quitado de esta", other: "{n} contactos también estaban en otras libretas de direcciones y solo se han quitado de esta" },
},
};
+14
View File
@@ -1300,6 +1300,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Cette action est irréversible.",
"Some could not be deleted: {error}": "Certains nont pas pu être supprimés : {error}",
"It was not deleted": "Il na pas été supprimé",
"Empty address book": "Vider ce carnet dadresses",
"There is nothing in it to delete": "Il ny a rien à supprimer dedans",
"Empty “{name}”?": "Vider « {name} » ?",
"Delete them": "Les supprimer",
"Nothing was deleted": "Rien na été supprimé",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1351,5 +1360,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" },
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Supprimer {n} contact ?", other: "Supprimer {n} contacts ?" },
"Deleted {n} contacts": { one: "{n} contact supprimé", other: "{n} contacts supprimés" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact sera supprimé. Cette action est irréversible.", other: "{n} contacts seront supprimés. Cette action est irréversible." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contact se trouvait aussi dans un autre carnet dadresses et na été retiré que de celui-ci", other: "{n} contacts se trouvaient aussi dans dautres carnets dadresses et nont été retirés que de celui-ci" },
},
};
+14
View File
@@ -1303,6 +1303,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "この操作は取り消せません。",
"Some could not be deleted: {error}": "一部を削除できませんでした: {error}",
"It was not deleted": "削除されませんでした",
"Empty address book": "このアドレス帳を空にする",
"There is nothing in it to delete": "削除するものがありません",
"Empty “{name}”?": "「{name}」を空にしますか?",
"Delete them": "削除する",
"Nothing was deleted": "何も削除されませんでした",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1359,5 +1368,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
"in {n} folders": { other: "{n} 個のフォルダーで" },
"Deleted {n} messages": { other: "{n} 通のメールを削除しました" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { other: "{n} 件の連絡先を削除しますか?" },
"Deleted {n} contacts": { other: "{n} 件の連絡先を削除しました" },
"{n} contacts will be deleted. This cannot be undone.": { other: "{n} 件の連絡先が削除されます。この操作は取り消せません。" },
"{n} were also in other address books and were only removed from this one": { other: "{n} 件は他のアドレス帳にもあるため、このアドレス帳から外しただけです" },
},
};
+14
View File
@@ -1291,6 +1291,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Dit kan niet ongedaan worden gemaakt.",
"Some could not be deleted: {error}": "Sommige konden niet worden verwijderd: {error}",
"It was not deleted": "Het is niet verwijderd",
"Empty address book": "Dit adresboek leegmaken",
"There is nothing in it to delete": "Er staat niets in om te verwijderen",
"Empty “{name}”?": "„{name}” leegmaken?",
"Delete them": "Verwijderen",
"Nothing was deleted": "Er is niets verwijderd",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1342,5 +1351,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} bericht als gelezen gemarkeerd", other: "{n} berichten als gelezen gemarkeerd" },
"in {n} folders": { one: "in {n} map", other: "in {n} mappen" },
"Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "{n} contact verwijderen?", other: "{n} contacten verwijderen?" },
"Deleted {n} contacts": { one: "{n} contact verwijderd", other: "{n} contacten verwijderd" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "{n} contacten worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contact stond ook in een ander adresboek en is alleen uit dit adresboek verwijderd", other: "{n} contacten stonden ook in andere adresboeken en zijn alleen uit dit adresboek verwijderd" },
},
};
+14
View File
@@ -1298,6 +1298,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Isso não pode ser desfeito.",
"Some could not be deleted: {error}": "Alguns não puderam ser excluídos: {error}",
"It was not deleted": "Não foi excluído",
"Empty address book": "Esvaziar este catálogo de endereços",
"There is nothing in it to delete": "Não há nada nele para excluir",
"Empty “{name}”?": "Esvaziar “{name}”?",
"Delete them": "Excluir todos",
"Nothing was deleted": "Nada foi excluído",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1349,5 +1358,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} mensagem marcada como lida", other: "{n} mensagens marcadas como lidas" },
"in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" },
"Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Excluir {n} contato?", other: "Excluir {n} contatos?" },
"Deleted {n} contacts": { one: "{n} contato excluído", other: "{n} contatos excluídos" },
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contato será excluído. Isso não pode ser desfeito.", other: "{n} contatos serão excluídos. Isso não pode ser desfeito." },
"{n} were also in other address books and were only removed from this one": { one: "{n} contato também estava em outro catálogo de endereços e foi removido apenas deste", other: "{n} contatos também estavam em outros catálogos de endereços e foram removidos apenas deste" },
},
};
+14
View File
@@ -1297,6 +1297,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Это действие нельзя отменить.",
"Some could not be deleted: {error}": "Некоторые не удалось удалить: {error}",
"It was not deleted": "Контакт не был удалён",
"Empty address book": "Очистить эту адресную книгу",
"There is nothing in it to delete": "В ней нечего удалять",
"Empty “{name}”?": "Очистить «{name}»?",
"Delete them": "Удалить их",
"Nothing was deleted": "Ничего не удалено",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1356,5 +1365,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
"in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" },
"Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Удалить {n} контакт?", few: "Удалить {n} контакта?", many: "Удалить {n} контактов?", other: "Удалить {n} контакта?" },
"Deleted {n} contacts": { one: "Удалён {n} контакт", few: "Удалено {n} контакта", many: "Удалено {n} контактов", other: "Удалено {n} контакта" },
"{n} contacts will be deleted. This cannot be undone.": { one: "Будет удалён {n} контакт. Это действие нельзя отменить.", few: "Будет удалено {n} контакта. Это действие нельзя отменить.", many: "Будет удалено {n} контактов. Это действие нельзя отменить.", other: "Будет удалено {n} контакта. Это действие нельзя отменить." },
"{n} were also in other address books and were only removed from this one": { one: "{n} контакт также был в другой адресной книге и удалён только из этой", few: "{n} контакта также были в других адресных книгах и удалены только из этой", many: "{n} контактов также были в других адресных книгах и удалены только из этой", other: "{n} контакта также были в других адресных книгах и удалены только из этой" },
},
};
+14
View File
@@ -1291,6 +1291,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "Цю дію не можна скасувати.",
"Some could not be deleted: {error}": "Деякі не вдалося видалити: {error}",
"It was not deleted": "Контакт не було видалено",
"Empty address book": "Очистити цю адресну книгу",
"There is nothing in it to delete": "У ній немає чого видаляти",
"Empty “{name}”?": "Очистити «{name}»?",
"Delete them": "Видалити їх",
"Nothing was deleted": "Нічого не видалено",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1348,5 +1357,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
"in {n} folders": { one: "у {n} теці", few: "у {n} теках", many: "у {n} теках", other: "у {n} теках" },
"Deleted {n} messages": { one: "Видалено {n} лист", few: "Видалено {n} листи", many: "Видалено {n} листів", other: "Видалено {n} листа" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { one: "Видалити {n} контакт?", few: "Видалити {n} контакти?", many: "Видалити {n} контактів?", other: "Видалити {n} контакта?" },
"Deleted {n} contacts": { one: "Видалено {n} контакт", few: "Видалено {n} контакти", many: "Видалено {n} контактів", other: "Видалено {n} контакта" },
"{n} contacts will be deleted. This cannot be undone.": { one: "Буде видалено {n} контакт. Цю дію не можна скасувати.", few: "Буде видалено {n} контакти. Цю дію не можна скасувати.", many: "Буде видалено {n} контактів. Цю дію не можна скасувати.", other: "Буде видалено {n} контакта. Цю дію не можна скасувати." },
"{n} were also in other address books and were only removed from this one": { one: "{n} контакт також був в іншій адресній книзі й вилучений лише з цієї", few: "{n} контакти також були в інших адресних книгах і вилучені лише з цієї", many: "{n} контактів також були в інших адресних книгах і вилучені лише з цієї", other: "{n} контакта також були в інших адресних книгах і вилучені лише з цієї" },
},
};
+14
View File
@@ -1302,6 +1302,15 @@ export const catalog: Catalog = {
// Sentences that lib/ and store/ were building in English, and the two
// swipe labels that reach t() through a variable and so were invisible
// to a scan for t("literal"). See #259.
// ── Emptying an address book, and deleting a selection (#277) ──
"This cannot be undone.": "此操作无法撤销。",
"Some could not be deleted: {error}": "部分无法删除:{error}",
"It was not deleted": "未被删除",
"Empty address book": "清空此通讯录",
"There is nothing in it to delete": "其中没有可删除的内容",
"Empty “{name}”?": "清空“{name}”?",
"Delete them": "删除",
"Nothing was deleted": "未删除任何内容",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
@@ -1358,5 +1367,10 @@ export const catalog: Catalog = {
"Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" },
"in {n} folders": { other: "在 {n} 个文件夹中" },
"Deleted {n} messages": { other: "已删除 {n} 封邮件" },
// ── Emptying an address book, and deleting a selection (#277) ──
"Delete {n} contacts?": { other: "删除 {n} 位联系人?" },
"Deleted {n} contacts": { other: "已删除 {n} 位联系人" },
"{n} contacts will be deleted. This cannot be undone.": { other: "将删除 {n} 位联系人。此操作无法撤销。" },
"{n} were also in other address books and were only removed from this one": { other: "其中 {n} 位也在其他通讯录中,仅从此通讯录移除" },
},
};