diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 9fcc049..55d4514 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -65,6 +65,14 @@ function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, /// une suppression qui s'y arreterait ne se propagerait donc nulle part. function DeleteEntry(const APlainJson, AUuid: string): string; +/// Duplique une entree : meme contenu, uuid neuf, nom suffixe. +/// +/// COPIE INTEGRALE, pieces jointes comprises. Le poste fait de meme, et une +/// copie qui perdrait le document attache serait une surprise le jour ou l'on +/// s'en apercevrait - c'est-a-dire trop tard. Le prix est un instantane plus +/// lourd a publier ; il se voit tout de suite, lui. +function DuplicateEntry(const APlainJson, AUuid: string): string; + /// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat. /// /// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne @@ -258,6 +266,58 @@ begin end; end; +function DuplicateEntry(const APlainJson, AUuid: string): string; +var + LRoot, LEntry, LCopy: TJSONObject; + LEntries: TJSONArray; + I: Integer; + LNow, LTitle: string; +begin + if AUuid = '' then + raise EPMAPatch.Create('Entree sans uuid : rien a dupliquer'); + + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then + raise EPMAPatch.Create('Instantane illisible'); + try + if not LRoot.TryGetValue('entries', LEntries) then + raise EPMAPatch.Create('Instantane sans tableau "entries"'); + + LCopy := nil; + for I := 0 to LEntries.Count - 1 do + begin + if not (LEntries.Items[I] is TJSONObject) then Continue; + LEntry := LEntries.Items[I] as TJSONObject; + if LEntry.GetValue('uuid', '') <> AUuid then Continue; + // Reparser plutot que recopier champ par champ : c'est le seul moyen qui + // n'oublie rien de ce que le telephone ne comprend pas. + LCopy := CloneJson(LEntry); + Break; + end; + if LCopy = nil then + raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]); + + LNow := NowUtcIso; + LCopy.RemovePair('uuid').Free; + LCopy.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36))); + LCopy.RemovePair('created_at').Free; + LCopy.AddPair('created_at', LNow); + LCopy.RemovePair('updated_at').Free; + LCopy.AddPair('updated_at', LNow); + // Le nom change, sinon les deux lignes sont indiscernables dans la liste. + // Retirer PUIS ajouter, comme partout ici : AddPair seul laisserait deux + // paires 'title', et le lecteur prendrait la premiere - donc l'ancienne. + LTitle := Trim(LCopy.GetValue('title', '') + ' (copy)'); + LCopy.RemovePair('title').Free; + LCopy.AddPair('title', LTitle); + LEntries.AddElement(LCopy); + + Result := LRoot.ToJSON; + finally + LRoot.Free; + end; +end; + function DeleteEntry(const APlainJson, AUuid: string): string; var LRoot, LEntry, LStone: TJSONObject; diff --git a/android-client/web/app.html b/android-client/web/app.html index 6258903..1114c37 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -215,6 +215,15 @@ padding: 14px 16px; } .menu button:active { background: rgba(255,255,255,.10); } .menu button.danger { color: #ffb4b4; } + /* LA BARRE DE SELECTION prend la place de la recherche, comme dans les + applications d'Android : on ne cherche pas pendant qu'on choisit quoi + supprimer, et deux rangees de commandes se disputeraient le pouce. */ + #selbar { display: none; } + body.selecting #selbar { display: flex; } + body.selecting .top { display: none; } + #selcount { flex: 1; font-size: 16px; font-weight: 600; } + /* La ligne choisie se voit, sinon on supprime a l'aveugle. */ + .row.sel { outline: 2px solid var(--accent); outline-offset: -2px; } .field .line { display: flex; align-items: center; gap: 8px; } .field .val { flex: 1; min-width: 0; word-break: break-all; @@ -402,9 +411,14 @@ +
+ +
+ + +
-
@@ -599,9 +613,13 @@ document.getElementById('rows').addEventListener('click', function (ev) { var btn = ev.target.closest('[data-copy]'); if (btn) { send('copy?i=' + row.getAttribute('data-i') + '&f=' + btn.getAttribute('data-copy')); return; } - // Le menu vient de s'ouvrir sur cette ligne : le clic qui suit l'appui long - // ouvrirait l'entree DERRIERE le menu. - if (!document.getElementById('menuList').hidden) return; + // EN SELECTION, UN APPUI CHOISIT. Ouvrir une entree pendant qu'on en + // designe d'autres n'a pas de sens, et c'est aussi la sortie : deselectionner + // la derniere ligne referme la barre. + if (document.body.classList.contains('selecting')) { + toggleSel(row.getAttribute('data-i')); + return; + } send('open?i=' + row.getAttribute('data-i')); }); @@ -650,6 +668,8 @@ var ICONS = { edit: '', eye: '' + '', + trash: '' + + '', dots: '' + '', check: '', @@ -1049,23 +1069,18 @@ document.getElementById('new').addEventListener('click', openNewEntry); // pierre tombale fait disparaitre l'entree du poste aussi, sans passer par la // sienne. Le seul filet est la sauvegarde du poste. function closeMenu() { - ['menu', 'menuList'].forEach(function (id) { - var m = document.getElementById(id); - if (!m) return; - m.hidden = true; - m.innerHTML = ''; - }); + var m = document.getElementById('menu'); + m.hidden = true; + m.innerHTML = ''; } /// AMenu vit dans l'ecran courant : le detail a le sien, la liste le sien. /// ATop, quand il est donne, ancre le menu a la hauteur de la ligne touchee - /// un menu qui apparait loin du doigt ne se rattache a rien. -function openMenu(AIndex, ATop) { - var m = document.getElementById(view === 'list' ? 'menuList' : 'menu'); - m.innerHTML = ''; - if (ATop !== undefined) m.style.top = ATop + 'px'; +function openMenu() { + var m = document.getElementById('menu'); + m.innerHTML = '' + + ''; m.hidden = false; } @@ -1078,6 +1093,11 @@ document.getElementById('kebab').addEventListener('click', function (ev) { function onMenuClick(ev) { var b = ev.target.closest('button'); if (!b) return; + if (b.getAttribute('data-dup') !== null) { + closeMenu(); + send('dupentry'); + return; + } if (b.getAttribute('data-del') !== null) { // Le premier appui n'arme que la confirmation. Ouvrir le menu etait deja // delibere ; ceci ferme la porte a l'appui qui derape dans la liste. @@ -1091,19 +1111,54 @@ function onMenuClick(ev) { b.textContent = 'Deleting...'; // L'index designe l'entree quand on supprime depuis la liste. Absent, c'est // l'entree ouverte - Delphi tient deja laquelle. - var i = b.getAttribute('data-i'); - send('delentry' + (i === null ? '' : '?i=' + i)); + send('delentry'); } document.getElementById('menu').addEventListener('click', onMenuClick); -document.getElementById('menuList').addEventListener('click', onMenuClick); -// APPUI LONG POUR SUPPRIMER DEPUIS LA LISTE. +// SELECTIONNER DANS LA LISTE - appui long, puis la barre du haut change. +// +// Le menu flottant qu'il y avait ici se refermait au premier appui suivant, +// donc ouvrir une entree apres coup demandait deux gestes et donnait +// l'impression qu'un bouton restait accroche. La barre contextuelle est le +// modele d'Android : elle prend la place de la recherche, elle dit combien de +// lignes sont prises, et elle disparait quand on n'en a plus. // // Ni une croix par ligne - une action sans retour a un pouce d'ecart sur une // liste qu'on fait defiler - ni un troisieme bouton par ligne, alors qu'on -// vient justement de les degager. L'appui long ne se voit pas tant qu'on ne -// s'en sert pas, c'est la convention d'Android pour agir sur un element de -// liste, et c'est la que se rangera la selection multiple si elle vient. +// vient de les degager. +var selected = {}; + +function selCount() { + var n = 0, k; + for (k in selected) if (selected[k]) n++; + return n; +} + +function renderSel() { + var n = selCount(), i, all = document.querySelectorAll('.row'); + document.body.classList.toggle('selecting', n > 0); + document.getElementById('selcount').textContent = + n + (n === 1 ? ' selected' : ' selected'); + for (i = 0; i < all.length; i++) + all[i].classList.toggle('sel', !!selected[all[i].getAttribute('data-i')]); + // Delphi doit le savoir : la touche retour ferme la selection avant de + // verrouiller le coffre, sinon un geste d'annulation le fermerait. + send('state?view=' + (n > 0 ? 'select' : 'list')); +} + +function clearSel() { selected = {}; renderSel(); } + +function toggleSel(i) { + if (selected[i]) delete selected[i]; else selected[i] = true; + renderSel(); +} + +function selList() { + var out = [], k; + for (k in selected) if (selected[k]) out.push(k); + return out.join(','); +} + (function () { var timer = null, moved = false; var rows = document.getElementById('rows'); @@ -1114,10 +1169,9 @@ document.getElementById('menuList').addEventListener('click', onMenuClick); moved = false; timer = setTimeout(function () { if (moved) return; - var r = row.getBoundingClientRect(); - openMenu(row.getAttribute('data-i'), Math.round(r.bottom) - 40); + toggleSel(row.getAttribute('data-i')); send('touch'); - }, 600); + }, 500); }); rows.addEventListener('touchmove', function () { moved = true; clearTimeout(timer); }); ['touchend', 'touchcancel'].forEach(function (e) { @@ -1125,11 +1179,41 @@ document.getElementById('menuList').addEventListener('click', onMenuClick); }); })(); +document.getElementById('selCancel').innerHTML = ico('close', 'Cancel selection'); +document.getElementById('selDup').innerHTML = ico('copy', 'Duplicate'); +document.getElementById('selDel').innerHTML = ico('trash', 'Delete'); +document.getElementById('selCancel').addEventListener('click', clearSel); +document.getElementById('selDup').addEventListener('click', function () { + var l = selList(); + if (!l) return; + clearSel(); + send('dupentry?i=' + l); +}); +document.getElementById('selDel').addEventListener('click', function (ev) { + var b = ev.currentTarget, l = selList(); + if (!l) return; + // Deux temps, comme partout ailleurs pour ce geste : il est sans retour. + if (b.getAttribute('data-armed') === null) { + b.setAttribute('data-armed', '1'); + b.innerHTML = ico('check', 'Confirm delete'); + setTimeout(function () { + b.removeAttribute('data-armed'); + b.innerHTML = ico('trash', 'Delete'); + }, 4000); + toast('Tap again to delete ' + selCount()); + return; + } + b.removeAttribute('data-armed'); + b.innerHTML = ico('trash', 'Delete'); + clearSel(); + send('delentry?i=' + l); +}); + // Un appui n'importe ou ailleurs referme le menu - y compris sur le contenu, // sinon il resterait ouvert pendant qu'on lit l'entree. document.addEventListener('click', function (ev) { if (document.getElementById('menu').hidden) return; - if (ev.target.closest('#menu, #menuList, #kebab')) return; + if (ev.target.closest('#menu, #kebab')) return; closeMenu(); }); @@ -1155,6 +1239,8 @@ var PM = { q.value = ''; query = ''; document.body.classList.remove('searching', 'on-detail'); document.body.classList.add('on-list'); + selected = {}; + document.body.classList.remove('selecting'); // LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le // mot de passe qu'on vient de taper ; le laisser dans le document apres // l'enregistrement le laisserait la jusqu'au verrouillage. @@ -1283,6 +1369,10 @@ var PM = { t._h = setTimeout(function () { t.classList.remove('on'); }, 2600); }, back: function () { if (view === 'detail') goList(); }, + /// La touche retour annule la selection avant de verrouiller le coffre. + /// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce + /// qu'il veut dire. + clearSelection: function () { clearSel(); }, // Efface tout ce qui est en clair. Appele au passage en arriere-plan et au // verrouillage : le tas JS n'est pas efface par le masquage de la vue. wipe: function () { diff --git a/mainApk.pas b/mainApk.pas index 8acd7ab..b57acf2 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -276,9 +276,12 @@ type /// comme 'setfield' - meme regle, meme journalisation muette. procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind, AFolder: string); - /// Supprime une entree, pierre tombale comprise. Sans retour. - /// AVaultIndex negatif = l'entree ouverte. - procedure DeleteEntryAt(AVaultIndex: Integer); + function UuidsFromIndexes(const AList: string): TArray; + /// Supprime des entrees, pierres tombales comprises. Sans retour. + /// Liste vide = l'entree ouverte. + procedure DeleteEntriesAt(const AList: string); + /// Duplique des entrees. Liste vide = l'entree ouverte. + procedure DuplicateEntriesAt(const AList: string); /// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot. procedure ReconcileWithRemote; procedure ReopenEntryByUuid(const AUuid: string); @@ -504,7 +507,15 @@ begin end; // C'est la PAGE qui sait ce qu'elle affiche - Delphi ne le devine pas. Elle // le declare a chaque changement de vue (cmd://state). - if FWebView = 'detail' then + // UNE SELECTION EN COURS S'ANNULE D'ABORD. Verrouiller le coffre parce qu'on + // a voulu annuler un choix serait le contraire de ce que le geste veut dire. + if FWebView = 'select' then + begin + WebCall('PM.clearSelection()'); + FWebView := 'list'; + Key := 0; + end + else if FWebView = 'detail' then begin WebCall('PM.back()'); FWebView := 'list'; @@ -1174,43 +1185,79 @@ begin end).Start; end; -procedure TForm8.DeleteEntryAt(AVaultIndex: Integer); +/// LES RANGS ENVOYES PAR LA PAGE -> LES UUID CORRESPONDANTS. +/// +/// Une liste vide veut dire "l'entree ouverte" : c'est le menu du detail, qui +/// n'a pas de rang a donner. La page n'envoie jamais d'uuid - elle en a un, +/// mais le faire voyager reviendrait a lui laisser designer une ligne que +/// Delphi n'a pas affichee. +function TForm8.UuidsFromIndexes(const AList: string): TArray; var - LUuid, LTitle: string; - E: TVaultEntry; + LPart: string; + LIdx: Integer; begin - // UN RANG, OU L'ENTREE OUVERTE. Depuis la liste il n'y a pas d'entree - // ouverte : le rang designe laquelle, exactement comme pour 'open' et - // 'copy'. La page n'envoie jamais d'uuid - elle en a un, mais le faire - // voyager reviendrait a lui laisser designer une ligne que Delphi n'a pas - // affichee. - if AVaultIndex >= 0 then + Result := nil; + if Trim(AList) = '' then begin - if AVaultIndex >= FVault.Count then - begin - RefuseChange('That entry is no longer there'); - Exit; - end; - E := FVault.Entry(AVaultIndex); - LUuid := E.Uuid; - LTitle := E.Display; - end - else - begin - LUuid := FCurrent.Uuid; - LTitle := FCurrent.Display; - end; - if LUuid = '' then - begin - RefuseChange('This entry has no uuid - cannot be deleted'); + if FCurrent.Uuid <> '' then Result := [FCurrent.Uuid]; Exit; end; - // Pas de rang a rouvrir : l'entree n'existera plus. AdoptVault ramene la + for LPart in AList.Split([',']) do + begin + LIdx := StrToIntDef(Trim(LPart), -1); + if (LIdx < 0) or (LIdx >= FVault.Count) then Continue; + if FVault.Entry(LIdx).Uuid = '' then Continue; + Result := Result + [FVault.Entry(LIdx).Uuid]; + end; +end; + +procedure TForm8.DeleteEntriesAt(const AList: string); +var + LUuids: TArray; +begin + LUuids := UuidsFromIndexes(AList); + if Length(LUuids) = 0 then + begin + RefuseChange('Nothing to delete'); + Exit; + end; + // UNE SEULE PUBLICATION POUR TOUTES. Le conteneur monte en entier a chaque + // ecriture : supprimer dix entrees une par une couterait dix fois ce trajet, + // et neuf occasions de le voir echouer a mi-chemin. + // + // Pas de rang a rouvrir : les entrees n'existeront plus. AdoptVault ramene la // liste, ce qui est exactement ou l'on veut se retrouver. - CommitChange('Deleted: ' + LTitle, 'entry deleted from phone', '', + CommitChange(Format('Deleted %d', [Length(LUuids)]), + 'entries deleted from phone', '', function (APlain: string): string + var + I: Integer; begin - Result := DeleteEntry(APlain, LUuid); + Result := APlain; + for I := 0 to High(LUuids) do + Result := DeleteEntry(Result, LUuids[I]); + end); +end; + +procedure TForm8.DuplicateEntriesAt(const AList: string); +var + LUuids: TArray; +begin + LUuids := UuidsFromIndexes(AList); + if Length(LUuids) = 0 then + begin + RefuseChange('Nothing to duplicate'); + Exit; + end; + CommitChange(Format('Duplicated %d', [Length(LUuids)]), + 'entries duplicated from phone', '', + function (APlain: string): string + var + I: Integer; + begin + Result := APlain; + for I := 0 to High(LUuids) do + Result := DuplicateEntry(Result, LUuids[I]); end); end; @@ -1378,7 +1425,10 @@ begin TNetEncoding.URL.Decode(Param('d'))) else if LAction = 'delentry' then - DeleteEntryAt(StrToIntDef(Param('i'), -1)) + DeleteEntriesAt(TNetEncoding.URL.Decode(Param('i'))) + + else if LAction = 'dupentry' then + DuplicateEntriesAt(TNetEncoding.URL.Decode(Param('i'))) else if LAction = 'setfield' then SaveEntryField(TNetEncoding.URL.Decode(Param('f')),