feat(android): entries can be created on the phone

A vault you cannot add to is not a vault, so an account created here was
useless without this. A plus button next to the padlock, a form, and a
kind - login or note, which the vault distinguishes and whose body is the
password field either way.

One save path now, two transports, and the rule that decides between them
is: if a local container exists it is the authority. A vault born here has
no repository; a vault pulled from one has no local container. The case
where both exist - a local vault that later gains a repository - needs a
merge, and that is section 7 step 4.

The patch arrives as a function rather than an already-patched snapshot:
the remote path must modify WHAT IT HAS JUST PULLED, or it would overwrite
whatever another device published in between. That constraint is why the
two transports could not simply share a finished payload.

Two traps closed while passing: the form left the typed password sitting
in the document after the save, and a refusal left its Save button dead
with the text behind it.

Verified on the device that the button and the form appear and read
correctly. Saving was not exercised - the vault on this phone syncs to a
real repository, and a test entry would have been published to it with no
way to delete it from here yet.
This commit is contained in:
r-zakarya
2026-08-21 14:22:08 +01:00
parent f32cde81fc
commit d65fe909b4
3 changed files with 295 additions and 71 deletions
+55
View File
@@ -40,6 +40,17 @@ function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): strin
/// naisse pas dans un dialecte a part.
function NewEmptySnapshot(const AUsername: string): string;
/// Ajoute une entree a l'instantane et rend le JSON complet.
///
/// LA MEME FORME QUE CE QU'ECRIT LE POSTE, cles vides comprises : une entree
/// nee ici sera relue la-bas, et une cle absente ne se comporte pas toujours
/// comme une cle vide. `folder` vaut 'All' parce que c'est le dossier par
/// defaut du poste, pas parce que c'est joli.
///
/// 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;
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
@@ -75,6 +86,50 @@ begin
end;
end;
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind: string): string;
var
LRoot, LEntry: TJSONObject;
LEntries: TJSONArray;
LNow: string;
begin
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Snapshot is not a JSON object');
try
LEntries := LRoot.GetValue('entries') as TJSONArray;
if LEntries = nil then
raise EPMAPatch.Create('Snapshot has no entries array');
LNow := NowUtcIso;
LEntry := TJSONObject.Create;
// Minuscules et sans accolades : la forme que tout le reste emploie.
LEntry.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
LEntry.AddPair('title', ATitle);
LEntry.AddPair('site', ASite);
LEntry.AddPair('username', AUsername);
// 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');
LEntry.AddPair('tags', TJSONArray.Create);
LEntry.AddPair('totp_secret', '');
LEntry.AddPair('kind', AKind);
LEntry.AddPair('template', '');
LEntry.AddPair('custom_fields', TJSONArray.Create);
LEntry.AddPair('attachments', TJSONArray.Create);
LEntry.AddPair('icon_b64', '');
LEntry.AddPair('icon_custom', TJSONNumber.Create(0));
LEntry.AddPair('created_at', LNow);
LEntry.AddPair('updated_at', LNow);
LEntries.AddElement(LEntry);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
var
LRoot, LEntry: TJSONObject;
+90
View File
@@ -323,6 +323,12 @@
autocapitalize="none" spellcheck="false">
<div class="clear" id="qx">&#215;</div>
</div>
<button class="ico" id="new" aria-label="New entry">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="ico" id="lock" aria-label="Lock vault">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
@@ -689,6 +695,80 @@ addEventListener('keydown', function (ev) {
if (save && !save.disabled) save.click();
});
// NOUVELLE ENTREE - dans l'ecran de DETAIL, pas dans une quatrieme vue.
//
// Le rail en porte trois, et chacune coute ses regles de transformation. Le
// detail a deja tout ce qu'il faut : un titre, un retour, une colonne qui
// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
// place a l'ecran, et c'est exactement ce que cette vue veut dire.
var newKind = 'login';
function newField(id, label, type) {
return '<div class="field"><div class="cap">' + label + '</div>'
+ '<div class="line"><input class="field-in" id="' + id + '" type="'
+ type + '" autocomplete="off" autocorrect="off" autocapitalize="none"'
+ ' spellcheck="false" style="margin:0;flex:1;min-width:0;width:auto">'
+ '</div></div>';
}
function openNewEntry() {
newKind = 'login';
detailRows = [];
document.getElementById('dtitle').textContent = 'New entry';
document.getElementById('fields').innerHTML =
'<div class="field"><div class="cap">Kind</div><div class="line">'
+ '<button class="btn main" data-kind="login">Login</button>'
+ '<button class="btn" data-kind="note">Note</button></div></div>'
+ newField('nTitle', 'Name', 'text')
+ newField('nSite', 'Site', 'text')
+ newField('nUser', 'Username', 'text')
// EN CLAIR pendant la saisie, comme l'editeur : on tape un mot de passe
// qu'aucune confirmation ne rattrape et qu'on ne peut relire nulle part.
+ newField('nPwd', 'Password', 'text')
+ '<div class="field"><div class="line">'
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
// pleine vient d'etre etire dans son conteneur.
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
+ '</div></div>';
document.body.classList.remove('on-list');
document.body.classList.add('on-detail');
view = 'detail';
send('state?view=detail');
}
// Un seul ecouteur pour le formulaire : ses boutons naissent et meurent avec
// lui, et en attacher a chaque ouverture les empilerait.
document.getElementById('fields').addEventListener('click', function (ev) {
var kind = ev.target.getAttribute('data-kind');
if (kind) {
newKind = kind;
var all = document.querySelectorAll('[data-kind]');
for (var i = 0; i < all.length; i++)
all[i].classList.toggle('main', all[i].getAttribute('data-kind') === kind);
// Le corps d'une note EST son mot de passe cote stockage : meme champ,
// autre intitule, et rien a masquer.
var caps = document.querySelectorAll('#fields .cap');
for (i = 0; i < caps.length; i++) {
if (caps[i].textContent === 'Password' || caps[i].textContent === 'Content')
caps[i].textContent = (kind === 'note') ? 'Content' : 'Password';
// Une note n'a ni site ni identifiant. Les laisser proposes revient a
// demander de remplir ce qui ne sera jamais affiche.
if (caps[i].textContent === 'Site' || caps[i].textContent === 'Username')
caps[i].parentNode.hidden = (kind === 'note');
}
return;
}
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'))
+ '&s=' + encodeURIComponent(v('nSite'))
+ '&u=' + encodeURIComponent(v('nUser'))
+ '&p=' + encodeURIComponent(document.getElementById('nPwd').value)
+ '&k=' + newKind);
});
document.getElementById('new').addEventListener('click', openNewEntry);
function goList() {
document.body.classList.remove('on-detail');
document.body.classList.add('on-list');
@@ -710,6 +790,11 @@ var PM = {
q.value = ''; query = '';
document.body.classList.remove('searching', 'on-detail');
document.body.classList.add('on-list');
// 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.
detailRows = [];
document.getElementById('fields').innerHTML = '';
view = 'list';
renderFolders();
renderRows();
@@ -719,6 +804,11 @@ var PM = {
/// immediats : l'editeur y restait ouvert, boutons desactives, et Cancel
/// lui-meme ne repondait plus.
editDone: function () {
// Le formulaire de creation passe par ici aussi : son bouton se desactive
// a l'envoi, et sans cela un refus le laissait mort avec le texte saisi
// derriere - a retaper sans savoir pourquoi.
var save = document.getElementById('nSave');
if (save) { save.disabled = false; return; }
if (lastDetail) renderDetail(lastDetail);
},