From b1c0c1e11b809a49b3bfc2ef413e92cb867ec224 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:39:38 +0100 Subject: [PATCH] feat(apk): notes de commandes, une commande copiee a la fois Bloc Commands en tete d'une note "commands", groupe par groupe, avec un bouton copier. ParseCommandNote est le portage du decoupage du poste (groupes, lignes continuees). La copie passe par un rang, comme les autres lignes du detail. Co-Authored-By: Claude Opus 5.5 --- android-client/PMA.WebUI.pas | 71 ++++++++++++++++++++++++++++++++++++ android-client/README.md | 16 ++++++++ android-client/web/app.html | 23 ++++++++++++ mainApk.pas | 8 ++++ 4 files changed, 118 insertions(+) diff --git a/android-client/PMA.WebUI.pas b/android-client/PMA.WebUI.pas index d059699..f2dcc3d 100644 --- a/android-client/PMA.WebUI.pas +++ b/android-client/PMA.WebUI.pas @@ -37,6 +37,19 @@ function VaultToJson(AVault: TVault): string; /// Les lignes d'une entree, dans l'ordre d'affichage. function DetailToJson(const AEntry: TVaultEntry): string; +type + TCommandItem = record + Group, Cmd: string; + end; + +/// NOTE DE COMMANDES (modele "commands"), meme regle que le poste +/// (js/app.js parseCommandNote) : "# titre" ouvre un groupe, chaque autre ligne +/// non vide est une commande ; une ligne finie par \, ` ou ^ se recolle a la +/// suivante, sur UNE ligne. Une ligne vide ou la fin du texte termine la +/// commande en cours. +function ParseCommandNote(const AText: string): TArray; +function IsCommandNote(const AEntry: TVaultEntry): Boolean; + /// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la /// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui. /// Faux si le rang ne designe rien. @@ -245,6 +258,49 @@ begin Result := LRows; end; +function IsCommandNote(const AEntry: TVaultEntry): Boolean; +begin + Result := AEntry.IsNote and SameText(AEntry.Template, 'commands'); +end; + +function ParseCommandNote(const AText: string): TArray; +var + LGroup, LPending, LLine, LPart: string; + LCont: Boolean; + + procedure Flush; + begin + if LPending <> '' then + begin + SetLength(Result, Length(Result) + 1); + Result[High(Result)].Group := LGroup; + Result[High(Result)].Cmd := LPending; + end; + LPending := ''; + end; + +begin + Result := nil; + LGroup := ''; + LPending := ''; + for LLine in AText.Replace(#13, '').Split([#10]) do + begin + var LTrim := LLine.Trim; + if LTrim = '' then begin Flush; Continue; end; + if (LPending = '') and LTrim.StartsWith('#') then + begin + LGroup := LTrim.TrimLeft(['#']).Trim; + Continue; + end; + LCont := CharInSet(LTrim[High(LTrim)], ['\', '`', '^']); + if LCont then LPart := Copy(LTrim, 1, Length(LTrim) - 1).TrimRight + else LPart := LTrim; + if LPending = '' then LPending := LPart else LPending := LPending + ' ' + LPart; + if not LCont then Flush; + end; + Flush; +end; + function DetailToJson(const AEntry: TVaultEntry): string; var LRoot: TJSONObject; @@ -278,6 +334,21 @@ begin if LRows[I].Multiline then LObj.AddPair('m', TJSONBool.Create(True)); LArr.AddElement(LObj); end; + // La liste des commandes, pour l'affichage. La copie passe par un rang + // (copy?f=cmd&k=), comme les lignes : Delphi relit le clair chez lui. + if IsCommandNote(AEntry) then + begin + var LCmds := ParseCommandNote(AEntry.Password); + var LCArr := TJSONArray.Create; + LRoot.AddPair('cmds', LCArr); + for I := 0 to High(LCmds) do + begin + LObj := TJSONObject.Create; + LObj.AddPair('g', LCmds[I].Group); + LObj.AddPair('c', LCmds[I].Cmd); + LCArr.AddElement(LObj); + end; + end; Result := LRoot.ToJSON; finally LRoot.Free; diff --git a/android-client/README.md b/android-client/README.md index fdf58f1..3eedcb0 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -447,6 +447,22 @@ l'ouverture, écriture 204, puis synchro du poste qui reprend la modification. Portmaster (entrant autorisé pour Python) — sans le second, le port restait fermé malgré la règle. +## Notes de commandes — 2026-10-10 + +Une note marquée **Commands** sur le poste (modèle `commands`) affiche en tête +un bloc *Commands* : une commande par ligne sous le titre de son groupe, avec +un bouton copier. Le texte entier reste dessous, dans *Content*. + +- **Même découpage que le poste** : `ParseCommandNote` (PMA.WebUI) est le + portage de `parseCommandNote` (js/app.js) — `# titre` ouvre un groupe, une + ligne finie par `\`, `` ` `` ou `^` se recolle à la suivante. Les deux + doivent dire la même chose ; éprouvé sur les mêmes cas. +- **La copie passe par un rang** (`copy?f=cmd&k=`), comme les autres lignes : + Delphi relit la note et copie la commande. +- **Ni remplissage ni masquage** ici : pas de fenêtre cible, et le corps des + notes est déjà affiché en clair. Une note devient « Commands » depuis le + poste (case *Command list*) ; la synchro porte le modèle. + ## Saisie : ce qui a résisté - **`user-select: none` du `body`** était hérité par les champs : le curseur ne diff --git a/android-client/web/app.html b/android-client/web/app.html index 6e4f6ba..e143824 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -279,6 +279,11 @@ .field .val.multi { white-space: pre-wrap; word-break: normal; overflow-wrap: anywhere; } .field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; } + /* Note de commandes : une commande par ligne, sous le titre de son groupe. */ + .field.cmds .cmd-group { font-size: 12px; font-weight: 600; opacity: .7; + text-transform: uppercase; margin: 10px 0 2px; } + .field.cmds .cmd-line { padding: 4px 0; } + .field.cmds .val.cmd { font-family: monospace; font-size: 14px; } .bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18); margin-top: 10px; overflow: hidden; } .bar i { display: block; height: 100%; background: var(--text); @@ -1089,6 +1094,23 @@ function renderDetail(d) { + '' + '
'; + // NOTE DE COMMANDES : une ligne par commande, groupees sous leur titre, avec + // un bouton copier. Le texte entier reste plus bas, dans "Content". + if (d.cmds && d.cmds.length) { + var lastG = null; + html += '
Commands
'; + for (i = 0; i < d.cmds.length; i++) { + var cm = d.cmds[i]; + if (cm.g !== lastG) { + lastG = cm.g; + if (cm.g) html += '
' + esc(cm.g) + '
'; + } + html += '
' + esc(cm.c) + '
' + + '
'; + } + html += '
'; + } for (i = 0; i < detailRows.length; i++) { var r = detailRows[i]; html += '
' + esc(r.c) + '
' @@ -1170,6 +1192,7 @@ document.getElementById('fields').addEventListener('click', function (ev) { var copy = t.getAttribute('data-copy'); if (copy === 'totp') { send('copy?f=totp'); return; } if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; } + if (copy === 'cmd') { send('copy?f=cmd&k=' + t.getAttribute('data-k')); return; } }); diff --git a/mainApk.pas b/mainApk.pas index b8ee1ae..548da89 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -2124,6 +2124,14 @@ begin if DetailRowAt(FCurrent, StrToIntDef(Param('k'), -1), LCaption, LValue) then CopyToClipboard(LValue, LCaption); end + // Une commande d'une note de commandes, par son rang dans la liste. + else if Param('f') = 'cmd' then + begin + var LCmds := ParseCommandNote(FCurrent.Password); + var LK := StrToIntDef(Param('k'), -1); + if (LK >= 0) and (LK <= High(LCmds)) then + CopyToClipboard(LCmds[LK].Cmd, 'Command'); + end else begin LIdx := StrToIntDef(Param('i'), -1);