diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 6620687..9fcc049 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -49,7 +49,7 @@ function NewEmptySnapshot(const AUsername: string): string; /// /// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore. function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, - AKind: string): string; + AKind, AFolder: string): string; /// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place. /// @@ -142,6 +142,37 @@ end; /// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et /// le seul qui n'oublie aucun champ. +/// Inscrit un dossier dans la liste du coffre s'il n'y figure pas deja. +/// +/// Ranger une entree dans un dossier ne suffit pas : c'est `folders` qui peuple +/// la colonne du poste, et un nom qui n'y figure pas donne une entree rangee +/// dans un dossier que rien n'affiche - elle semble disparue. Les deux vont +/// donc ensemble, et 'All' ne s'inscrit jamais : c'est le defaut, pas un +/// dossier. +procedure EnsureFolder(const ARoot: TJSONObject; const AName: string); +var + LFolders: TJSONArray; + LObj: TJSONObject; + I: Integer; +begin + if (AName = '') or (AName = 'All') then Exit; + if not ARoot.TryGetValue('folders', LFolders) then + begin + LFolders := TJSONArray.Create; + ARoot.AddPair('folders', LFolders); + end; + for I := 0 to LFolders.Count - 1 do + if (LFolders.Items[I] is TJSONObject) and + ((LFolders.Items[I] as TJSONObject).GetValue('name', '') = AName) then + Exit; + LObj := TJSONObject.Create; + LObj.AddPair('name', AName); + // Le poste lit aussi 'color' et 'icon' ; vides, il met ses defauts. + LObj.AddPair('color', ''); + LObj.AddPair('icon', ''); + LFolders.AddElement(LObj); +end; + function CloneJson(const AObj: TJSONObject): TJSONObject; begin Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject; @@ -279,7 +310,7 @@ begin end; function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, - AKind: string): string; + AKind, AFolder: string): string; var LRoot, LEntry: TJSONObject; LEntries: TJSONArray; @@ -303,7 +334,9 @@ begin // Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de // colonne separee, ni ici ni sur le poste. LEntry.AddPair('password', APassword); - LEntry.AddPair('folder', 'All'); + if AFolder <> '' then LEntry.AddPair('folder', AFolder) + else LEntry.AddPair('folder', 'All'); + EnsureFolder(LRoot, AFolder); LEntry.AddPair('tags', TJSONArray.Create); LEntry.AddPair('totp_secret', ''); LEntry.AddPair('kind', AKind); @@ -356,6 +389,7 @@ begin // reste plus ancien que le leur. LEntry.RemovePair('updated_at').Free; LEntry.AddPair('updated_at', NowUtcIso); + if AField = 'folder' then EnsureFolder(LRoot, AValue); LFound := True; Break; diff --git a/android-client/web/app.html b/android-client/web/app.html index b2e5d6a..97cf720 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -562,8 +562,14 @@ function renderRows() { document.getElementById('rows').addEventListener('click', function (ev) { var row = ev.target.closest('.row'); if (!row) return; - var copy = ev.target.getAttribute('data-copy'); - if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy); + // REMONTER AU BOUTON. Depuis que ces boutons portent un dessin, la cible de + // l'appui est le ou l'un de ses traits, pas le bouton : lire + // l'attribut sur la cible rendait null, et l'appui ouvrait l'entree au lieu + // de copier. Le doigt tombant tantot sur le glyphe tantot sur la marge, cela + // ne ratait qu'une fois sur deux - la pire des pannes a diagnostiquer. + var btn = ev.target.closest('[data-copy]'); + if (btn) send('copy?i=' + row.getAttribute('data-i') + + '&f=' + btn.getAttribute('data-copy')); else send('open?i=' + row.getAttribute('data-i')); }); @@ -677,7 +683,10 @@ function renderDetail(d) { } document.getElementById('fields').addEventListener('click', function (ev) { - var t = ev.target, k = t.getAttribute('data-show'); + // Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte + // l'intention. Tout ce qui suit lit et ecrit sur le bouton. + var t = ev.target.closest('button') || ev.target; + var k = t.getAttribute('data-show'); if (t.id === 'del') { if (t.getAttribute('data-armed') === null) { t.setAttribute('data-armed', '1'); @@ -778,9 +787,31 @@ function editorControl(k) { html += ''; + // CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore, + // et rien ne permettait de le nommer. Cette option echange la liste contre un + // champ libre, sans quitter l'editeur. + html += ''; return html + ''; } +var NEW_FOLDER = '\u0001new'; + +// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields +// parce que le controle nait et meurt avec l'editeur. +document.getElementById('fields').addEventListener('change', function (ev) { + if (ev.target.id !== 'editval' || ev.target.value !== NEW_FOLDER) return; + var line = ev.target.parentNode; + var input = document.createElement('input'); + input.className = 'field-in'; + input.id = 'editval'; + input.setAttribute('placeholder', 'folder name'); + input.setAttribute('autocomplete', 'off'); + input.setAttribute('autocapitalize', 'none'); + input.style.cssText = 'margin:0;flex:1;min-width:0;width:auto'; + line.replaceChild(input, ev.target); + input.focus(); +}); + // EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au // moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors // null sans un mot - le bouton semblerait ne rien faire. @@ -895,6 +926,20 @@ addEventListener('keydown', function (ev) { // place a l'ecran, et c'est exactement ce que cette vue veut dire. var newKind = 'login'; +/// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la +/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer. +function newFolderField() { + var names = ['All'], i; + for (i = 0; i < folders.length; i++) + if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]); + var html = '
Folder
' + + '
'; +} + function newField(id, label, type) { return '
' + label + '
' + '
Kind
' + '' + '
' + + newFolderField() + newField('nTitle', 'Name', 'text') + newField('nSite', 'Site', 'text') + newField('nUser', 'Username', 'text') @@ -953,7 +999,9 @@ document.getElementById('fields').addEventListener('click', function (ev) { if (ev.target.id !== 'nSave') return; var v = function (id) { return document.getElementById(id).value.trim(); }; ev.target.disabled = true; - send('newentry?t=' + encodeURIComponent(v('nTitle')) + var fold = document.getElementById('nFolder'); + send('newentry?d=' + encodeURIComponent(fold ? fold.value : 'All') + + '&t=' + encodeURIComponent(v('nTitle')) + '&s=' + encodeURIComponent(v('nSite')) + '&u=' + encodeURIComponent(v('nUser')) + '&p=' + encodeURIComponent(document.getElementById('nPwd').value) diff --git a/mainApk.pas b/mainApk.pas index f6acf92..f513ae2 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -274,7 +274,8 @@ type procedure SaveEntryField(const AField, ANewValue: string); /// Ajoute une entree au coffre. Le mot de passe voyage par le pont, /// comme 'setfield' - meme regle, meme journalisation muette. - procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); + procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind, + AFolder: string); /// Supprime l'entree ouverte, pierre tombale comprise. Sans retour. procedure DeleteCurrentEntry; /// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot. @@ -1192,7 +1193,8 @@ begin end); end; -procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); +procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind, + AFolder: string); begin // Une entree sans rien pour la reconnaitre serait une ligne muette dans la // liste, et rien ne permettrait de la retrouver pour la corriger. @@ -1204,7 +1206,7 @@ begin CommitChange('Added: ' + ATitle, 'new entry from phone', '', function (APlain: string): string begin - Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind); + Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder); end); end; @@ -1351,7 +1353,8 @@ begin TNetEncoding.URL.Decode(Param('s')), TNetEncoding.URL.Decode(Param('u')), TNetEncoding.URL.Decode(Param('p')), - TNetEncoding.URL.Decode(Param('k'))) + TNetEncoding.URL.Decode(Param('k')), + TNetEncoding.URL.Decode(Param('d'))) else if LAction = 'delentry' then DeleteCurrentEntry