feat(notes): notes de commandes, une commande copiee ou tapee a la fois
Modele "commands" : "# titre" ouvre un groupe, chaque ligne est une commande. Le corps reste du texte (aucune colonne, sync et APK inchanges). Panneau : liste avec Copier. Recherche rapide : la note s'ouvre sur sa liste filtrable ; Entree tape la commande sans Entree et sans vider le champ, Ctrl+Entree copie, Esc revient. Decoupage teste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1572,6 +1572,16 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
|
|||||||
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
|
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
|
||||||
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
|
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
|
||||||
filtree obligeait a refaire la recherche.
|
filtree obligeait a refaire la recherche.
|
||||||
|
- **Notes de commandes** (2026-10-10, modele `commands`) : le corps reste du
|
||||||
|
texte - `# titre` ouvre un groupe, chaque autre ligne non vide est une
|
||||||
|
commande (`parseCommandNote`, teste dans `js/tests/commands.test.js`). Aucune
|
||||||
|
colonne : le texte est deja chiffre, synchronise, exporte et lu par l'APK.
|
||||||
|
Panneau : case *Command list* (bascule le modele, compte dans `isSoDirty` via
|
||||||
|
`originalTemplate`) + liste avec **Copier** seulement - la fenetre au premier
|
||||||
|
plan y serait l'app elle-meme. Recherche rapide : choisir la note ouvre sa
|
||||||
|
liste (`qsCommands`), la saisie filtre, Entree **tape sans Entree** et
|
||||||
|
**sans Ctrl+A + Suppr** (`executeAutofill(..., noClear)` : dans un terminal
|
||||||
|
Ctrl+A ramene en debut de ligne), Ctrl+Entree copie, Esc revient aux entrees.
|
||||||
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
|
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
|
||||||
texte ; match sur le label de section garde toute la section. Esc dans
|
texte ; match sur le label de section garde toute la section. Esc dans
|
||||||
la search avec query = clear ; Esc vide = ferme Settings.
|
la search avec query = clear ; Esc vide = ferme Settings.
|
||||||
|
|||||||
@@ -1979,6 +1979,14 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
.so-note-body.is-masked,
|
.so-note-body.is-masked,
|
||||||
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
|
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
|
||||||
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
/* Note de commandes : une ligne par commande, groupees sous leur titre. */
|
||||||
|
.so-cmds-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
||||||
|
.so-cmds-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.so-cmds-group { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-dim); margin-top: 8px; }
|
||||||
|
.so-cmds-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.so-cmds-cmd { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||||
|
.so-cmds-empty { font-size: 12px; color: var(--text-dim); }
|
||||||
|
.qs-cmd { font-family: ui-monospace, Consolas, monospace; font-size: 13px; }
|
||||||
|
|
||||||
/* ---- + New dropdown ---------------------------------- */
|
/* ---- + New dropdown ---------------------------------- */
|
||||||
.new-entry-wrap {
|
.new-entry-wrap {
|
||||||
|
|||||||
@@ -371,6 +371,10 @@
|
|||||||
<svg><use href="#i-globe"/></svg>
|
<svg><use href="#i-globe"/></svg>
|
||||||
<span>Server credentials</span>
|
<span>Server credentials</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="dropdown-item" data-new-template="commands">
|
||||||
|
<svg><use href="#i-edit"/></svg>
|
||||||
|
<span>Commands (note)</span>
|
||||||
|
</button>
|
||||||
<button class="dropdown-item" data-new-template="recovery-codes">
|
<button class="dropdown-item" data-new-template="recovery-codes">
|
||||||
<svg><use href="#i-shield"/></svg>
|
<svg><use href="#i-shield"/></svg>
|
||||||
<span>Recovery codes (note)</span>
|
<span>Recovery codes (note)</span>
|
||||||
|
|||||||
@@ -143,14 +143,14 @@ const Bridge = (() => {
|
|||||||
// back to the tray AFTER SendInput completes — necessary for the
|
// back to the tray AFTER SendInput completes — necessary for the
|
||||||
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
|
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
|
||||||
// Win10/11 anti-focus-stealing rules block the target).
|
// Win10/11 anti-focus-stealing rules block the target).
|
||||||
executeAutofill(username, password, hideAfter, field) {
|
executeAutofill(username, password, hideAfter, field, noClear) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
// field='user' → type only the username (no Tab / password).
|
// field='user' → type only the username (no Tab / password).
|
||||||
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
|
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
|
||||||
'&password=' + encodeURIComponent(password) +
|
'&password=' + encodeURIComponent(password) +
|
||||||
(hideAfter ? '&hide_after=1' : '') +
|
(hideAfter ? '&hide_after=1' : '') +
|
||||||
(field === 'user' ? '&field=user' : '') +
|
(field === 'user' ? '&field=user' : '') +
|
||||||
(state.autofillClearField ? '' : '&clear=0') +
|
(state.autofillClearField && !noClear ? '' : '&clear=0') +
|
||||||
(state.autofillFailBalloon ? '' : '¬ify=0'));
|
(state.autofillFailBalloon ? '' : '¬ify=0'));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2898,6 +2898,7 @@ function templateLabel(tpl) {
|
|||||||
case 'ssh-key': return 'SSH key';
|
case 'ssh-key': return 'SSH key';
|
||||||
case 'server': return 'Server';
|
case 'server': return 'Server';
|
||||||
case 'recovery-codes': return 'Recovery codes';
|
case 'recovery-codes': return 'Recovery codes';
|
||||||
|
case 'commands': return 'Commands';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4015,6 +4016,14 @@ const ENTRY_TEMPLATES = {
|
|||||||
{ label: 'Root password', value: '', is_secret: true },
|
{ label: 'Root password', value: '', is_secret: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// NOTE DE COMMANDES : le corps reste un texte (deja chiffre, synchronise,
|
||||||
|
// exporte, lu par l'APK) ; seul l'affichage le decoupe (parseCommandNote).
|
||||||
|
'commands': {
|
||||||
|
kind: 'note',
|
||||||
|
title: 'Commands',
|
||||||
|
body: '# Group\nssh user@host\n',
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
'recovery-codes': {
|
'recovery-codes': {
|
||||||
kind: 'note',
|
kind: 'note',
|
||||||
title: 'Recovery codes',
|
title: 'Recovery codes',
|
||||||
@@ -4026,9 +4035,21 @@ const ENTRY_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open the slideover for either an existing entry (id = number) or a
|
// DECOUPE D'UNE NOTE DE COMMANDES. "# titre" ouvre un groupe, toute autre ligne
|
||||||
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
|
// non vide est une commande, les lignes vides sont ignorees. Rend
|
||||||
// pre-fill the corresponding fields for the new-entry path.
|
// [{group, cmd}] dans l'ordre du texte. Pur : tests/commands.test.js.
|
||||||
|
function parseCommandNote(text) {
|
||||||
|
const out = [];
|
||||||
|
let group = '';
|
||||||
|
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith('#')) { group = line.replace(/^#+\s*/, ''); continue; }
|
||||||
|
out.push({ group, cmd: line });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// HISTORIQUE DES ENTREES OUVERTES, comme celui d'un navigateur. Fonctions
|
// HISTORIQUE DES ENTREES OUVERTES, comme celui d'un navigateur. Fonctions
|
||||||
// pures sur {ids, pos} : testables sans DOM (tests/history.test.js).
|
// pures sur {ids, pos} : testables sans DOM (tests/history.test.js).
|
||||||
const ENTRY_HISTORY_MAX = 50;
|
const ENTRY_HISTORY_MAX = 50;
|
||||||
@@ -4068,6 +4089,9 @@ function renderEntryHistoryNav() {
|
|||||||
if (fwd) fwd.disabled = entryHistoryTarget(h, +1, exists) < 0;
|
if (fwd) fwd.disabled = entryHistoryTarget(h, +1, exists) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open the slideover for either an existing entry (id = number) or a
|
||||||
|
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
|
||||||
|
// pre-fill the corresponding fields for the new-entry path.
|
||||||
async function openSlideOver(id, opts) {
|
async function openSlideOver(id, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
// A blocking operation owns the screen (export, sync, rotation). The busy
|
// A blocking operation owns the screen (export, sync, rotation). The busy
|
||||||
@@ -4207,6 +4231,7 @@ async function openSlideOver(id, opts) {
|
|||||||
// injected by the template picker on new. Sent back to the server
|
// injected by the template picker on new. Sent back to the server
|
||||||
// on Save so the card/table can label it correctly.
|
// on Save so the card/table can label it correctly.
|
||||||
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
|
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
|
||||||
|
originalTemplate: isNew ? '' : (e.template || ''),
|
||||||
// Attachments staged BEFORE the entry exists server-side. Empty on
|
// Attachments staged BEFORE the entry exists server-side. Empty on
|
||||||
// edit (existing attachments are fetched live via the dedicated
|
// edit (existing attachments are fetched live via the dedicated
|
||||||
// /entries/:id/attachments endpoint). Flushed by soSave after the
|
// /entries/:id/attachments endpoint). Flushed by soSave after the
|
||||||
@@ -4237,6 +4262,7 @@ async function openSlideOver(id, opts) {
|
|||||||
// Notes: minimal layout — name + multiline body + folder + tags.
|
// Notes: minimal layout — name + multiline body + folder + tags.
|
||||||
// No icon (covered by sidebar icon), no site/user/totp.
|
// No icon (covered by sidebar icon), no site/user/totp.
|
||||||
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
|
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
|
||||||
|
body.appendChild(soCommandsField());
|
||||||
body.appendChild(soNoteBodyField(plain));
|
body.appendChild(soNoteBodyField(plain));
|
||||||
const hist = soHistoryButton();
|
const hist = soHistoryButton();
|
||||||
if (hist) body.appendChild(hist);
|
if (hist) body.appendChild(hist);
|
||||||
@@ -4613,6 +4639,66 @@ function buildCustomFieldRow(field, idx, rerender) {
|
|||||||
|
|
||||||
// Multiline note body. Maps to soState.original.password and the
|
// Multiline note body. Maps to soState.original.password and the
|
||||||
// encrypted_password+iv columns (same crypto pipeline as login passwords).
|
// encrypted_password+iv columns (same crypto pipeline as login passwords).
|
||||||
|
// Liste des commandes d'une note : visible quand le modele est "commands",
|
||||||
|
// suit le texte au fil de la frappe. Une case bascule le modele (sauvegarde =
|
||||||
|
// enregistrement de l'entree). Copier seulement : ici la cible d'un
|
||||||
|
// remplissage serait l'application elle-meme - le remplissage passe par la
|
||||||
|
// recherche rapide (Ctrl+Shift+Q), qui connait la fenetre d'origine.
|
||||||
|
function soCommandsField() {
|
||||||
|
const wrap = el('div', { class: 'slideover-field so-cmds' });
|
||||||
|
const lbl = el('label', { class: 'so-cmds-toggle' });
|
||||||
|
const cb = el('input', { type: 'checkbox' });
|
||||||
|
cb.checked = soState.template === 'commands';
|
||||||
|
lbl.appendChild(cb);
|
||||||
|
lbl.appendChild(document.createTextNode(' Command list (# Group, one command per line)'));
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
const list = el('div', { class: 'so-cmds-list' });
|
||||||
|
wrap.appendChild(list);
|
||||||
|
const draw = () => {
|
||||||
|
list.innerHTML = '';
|
||||||
|
list.hidden = soState.template !== 'commands';
|
||||||
|
if (list.hidden) return;
|
||||||
|
const ta = document.getElementById('soNoteBody');
|
||||||
|
const cmds = parseCommandNote(ta ? ta.value : '');
|
||||||
|
if (!cmds.length) {
|
||||||
|
list.appendChild(el('div', { class: 'so-cmds-empty' }, 'No command yet - type them below.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let last = null;
|
||||||
|
cmds.forEach(c => {
|
||||||
|
if (c.group !== last) {
|
||||||
|
last = c.group;
|
||||||
|
if (c.group) list.appendChild(el('div', { class: 'so-cmds-group' }, c.group));
|
||||||
|
}
|
||||||
|
const row = el('div', { class: 'so-cmds-row' });
|
||||||
|
row.appendChild(el('code', { class: 'so-cmds-cmd' }, c.cmd));
|
||||||
|
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy command' });
|
||||||
|
b.appendChild(icon('i-copy'));
|
||||||
|
b.addEventListener('click', async ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (Bridge.active) Bridge.copySecure(c.cmd, 30000);
|
||||||
|
else { try { await navigator.clipboard.writeText(c.cmd); } catch (_) {} }
|
||||||
|
toast('Command copied');
|
||||||
|
});
|
||||||
|
row.appendChild(b);
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
cb.addEventListener('change', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
soState.template = cb.checked ? 'commands' : '';
|
||||||
|
draw();
|
||||||
|
soDirtyCheck();
|
||||||
|
});
|
||||||
|
// Le textarea est ajoute juste apres : on s'y branche au tick suivant.
|
||||||
|
setTimeout(() => {
|
||||||
|
const ta = document.getElementById('soNoteBody');
|
||||||
|
if (ta) ta.addEventListener('input', draw);
|
||||||
|
draw();
|
||||||
|
}, 0);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
function soNoteBodyField(value) {
|
function soNoteBodyField(value) {
|
||||||
const wrap = el('div', { class: 'slideover-field' });
|
const wrap = el('div', { class: 'slideover-field' });
|
||||||
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
|
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
|
||||||
@@ -5093,6 +5179,9 @@ function isSoDirty() {
|
|||||||
tags: soState.tags.join(','),
|
tags: soState.tags.join(','),
|
||||||
};
|
};
|
||||||
const curCustom = JSON.stringify(soState.customFields || []);
|
const curCustom = JSON.stringify(soState.customFields || []);
|
||||||
|
// La case "Command list" change le modele sans toucher au texte.
|
||||||
|
if (soState.id != null && (soState.template || '') !== (soState.originalTemplate || ''))
|
||||||
|
return true;
|
||||||
if (soState.id == null) {
|
if (soState.id == null) {
|
||||||
const orig = soState.original || {};
|
const orig = soState.original || {};
|
||||||
const staged = (soState.pendingAttachments || []).length > 0;
|
const staged = (soState.pendingAttachments || []).length > 0;
|
||||||
@@ -8824,6 +8913,7 @@ async function init() {
|
|||||||
// document-level handlers, which now see "no modal open"
|
// document-level handlers, which now see "no modal open"
|
||||||
// (we just closed it) and also close the dirty slideover.
|
// (we just closed it) and also close the dirty slideover.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (qsCommands) { qsLeaveCommands(); return; } // liste -> entrees
|
||||||
closeQuickSearchModal();
|
closeQuickSearchModal();
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -8835,6 +8925,11 @@ async function init() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sel = rows[quickSearchSelected];
|
const sel = rows[quickSearchSelected];
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
|
if (qsCommands) {
|
||||||
|
qsPickCommand(qsCommands[parseInt(sel.dataset.cmd, 10)].cmd,
|
||||||
|
e.ctrlKey || e.metaKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = parseInt(sel.dataset.id, 10);
|
const id = parseInt(sel.dataset.id, 10);
|
||||||
const entry = state.entries.find(x => x.id === id);
|
const entry = state.entries.find(x => x.id === id);
|
||||||
// Shift+Enter → full (user + Tab + password), Ctrl+Enter →
|
// Shift+Enter → full (user + Tab + password), Ctrl+Enter →
|
||||||
|
|||||||
+98
-2
@@ -33,6 +33,87 @@ let quickSearchFillMode = false;
|
|||||||
// DPAPI : localStorage se vide a chaque lancement (port ephemere).
|
// DPAPI : localStorage se vide a chaque lancement (port ephemere).
|
||||||
let quickSearchPreferCopy = false;
|
let quickSearchPreferCopy = false;
|
||||||
|
|
||||||
|
// NOTE DE COMMANDES ouverte dans la recherche rapide : la liste remplace les
|
||||||
|
// entrees, la saisie la filtre, Esc revient aux entrees. null = liste normale.
|
||||||
|
let qsCommands = null;
|
||||||
|
|
||||||
|
async function qsEnterCommands(entry) {
|
||||||
|
const body = await decryptPwd(entry.encrypted_password, entry.iv);
|
||||||
|
if (body === '[ERROR]') { toast('Decryption error', 'error'); return; }
|
||||||
|
qsCommands = parseCommandNote(body);
|
||||||
|
const input = document.getElementById('quickSearchInput');
|
||||||
|
input.value = '';
|
||||||
|
input.placeholder = entryDisplayName(entry) + ' - filter commands… (Esc = back)';
|
||||||
|
quickSearchSelected = 0;
|
||||||
|
qsApplyMode();
|
||||||
|
quickSearchRender();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function qsLeaveCommands() {
|
||||||
|
qsCommands = null;
|
||||||
|
const input = document.getElementById('quickSearchInput');
|
||||||
|
input.value = '';
|
||||||
|
input.placeholder = 'Search and press Enter to copy password…';
|
||||||
|
quickSearchSelected = 0;
|
||||||
|
qsApplyMode();
|
||||||
|
quickSearchRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une commande, une ligne, SANS Entree : l'utilisateur valide lui-meme. Ni
|
||||||
|
// Ctrl+A ni Suppr avant (noClear) - dans un terminal Ctrl+A ramene en debut de
|
||||||
|
// ligne, ce ne serait pas "tout selectionner".
|
||||||
|
async function qsPickCommand(cmd, forceCopy) {
|
||||||
|
if (qsFills() && !forceCopy) {
|
||||||
|
autofillPendingToast = 'Command sent';
|
||||||
|
if (Bridge.active) Bridge.executeAutofill('', cmd, quickSearchHideAfter, undefined, true);
|
||||||
|
quickSearchFillMode = false;
|
||||||
|
quickSearchHideAfter = false;
|
||||||
|
qsCommands = null;
|
||||||
|
closeQuickSearchModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Bridge.active) Bridge.copySecure(cmd, 30000);
|
||||||
|
else { try { await navigator.clipboard.writeText(cmd); } catch (_) {} }
|
||||||
|
toast('Command copied');
|
||||||
|
qsCommands = null;
|
||||||
|
closeQuickSearchModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qsRenderCommands(box, q) {
|
||||||
|
const ql = q.toLowerCase();
|
||||||
|
const list = qsCommands
|
||||||
|
.map((c, i) => ({ c, i }))
|
||||||
|
.filter(x => !ql || x.c.cmd.toLowerCase().includes(ql) || x.c.group.toLowerCase().includes(ql));
|
||||||
|
if (!list.length) {
|
||||||
|
box.appendChild(el('div', { class: 'quick-search-empty' },
|
||||||
|
q ? 'No command matches "' + q + '"' : 'No command in this note'));
|
||||||
|
quickSearchSelected = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quickSearchSelected >= list.length) quickSearchSelected = 0;
|
||||||
|
if (quickSearchSelected < 0) quickSearchSelected = list.length - 1;
|
||||||
|
list.forEach((x, n) => {
|
||||||
|
const row = el('div', {
|
||||||
|
class: 'quick-search-row' + (n === quickSearchSelected ? ' is-selected' : ''),
|
||||||
|
'data-cmd': String(x.i),
|
||||||
|
});
|
||||||
|
const main = el('div', { class: 'quick-search-main' });
|
||||||
|
main.appendChild(el('div', { class: 'quick-search-name qs-cmd' }, x.c.cmd));
|
||||||
|
if (x.c.group) main.appendChild(el('div', { class: 'quick-search-sub' }, x.c.group));
|
||||||
|
row.appendChild(main);
|
||||||
|
const acts = el('div', { class: 'quick-search-acts' });
|
||||||
|
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
|
||||||
|
title: qsFills() ? 'Copy instead of typing' : 'Copy command' });
|
||||||
|
b.appendChild(icon('i-copy'));
|
||||||
|
b.addEventListener('click', ev => { ev.stopPropagation(); qsPickCommand(x.c.cmd, true); });
|
||||||
|
acts.appendChild(b);
|
||||||
|
row.appendChild(acts);
|
||||||
|
row.addEventListener('click', ev => qsPickCommand(x.c.cmd, ev.ctrlKey || ev.metaKey));
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy.
|
/// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy.
|
||||||
function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; }
|
function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; }
|
||||||
|
|
||||||
@@ -44,7 +125,11 @@ function qsApplyMode() {
|
|||||||
b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills()));
|
b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills()));
|
||||||
}
|
}
|
||||||
const hintEl = document.querySelector('#quickSearchModal .quick-search-hint');
|
const hintEl = document.querySelector('#quickSearchModal .quick-search-hint');
|
||||||
if (hintEl) {
|
if (hintEl && qsCommands) {
|
||||||
|
hintEl.textContent = qsFills()
|
||||||
|
? 'Enter / click = type the command (no Enter sent) · Ctrl+Enter / Ctrl+click = copy · Esc = back'
|
||||||
|
: 'Enter / click = copy the command · Esc = back';
|
||||||
|
} else if (hintEl) {
|
||||||
hintEl.textContent = qsFills()
|
hintEl.textContent = qsFills()
|
||||||
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
|
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
|
||||||
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
|
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
|
||||||
@@ -80,6 +165,12 @@ function quickSearchScoreEntry(e, q) {
|
|||||||
|
|
||||||
function quickSearchRender() {
|
function quickSearchRender() {
|
||||||
const q = document.getElementById('quickSearchInput').value.trim();
|
const q = document.getElementById('quickSearchInput').value.trim();
|
||||||
|
if (qsCommands) {
|
||||||
|
const box = document.getElementById('quickSearchResults');
|
||||||
|
box.innerHTML = '';
|
||||||
|
qsRenderCommands(box, q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const list = state.entries
|
const list = state.entries
|
||||||
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
|
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
|
||||||
.filter(x => x.s > 0)
|
.filter(x => x.s > 0)
|
||||||
@@ -134,7 +225,9 @@ function quickSearchRender() {
|
|||||||
if (e.username) act('i-user', verb + 'username', 'user');
|
if (e.username) act('i-user', verb + 'username', 'user');
|
||||||
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
|
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
|
||||||
// retours a la ligne partirait comme un Entree dans le formulaire.
|
// retours a la ligne partirait comme un Entree dans le formulaire.
|
||||||
if (e.kind === 'note')
|
if (e.template === 'commands')
|
||||||
|
act('i-note', 'Open the command list', 'cmds');
|
||||||
|
else if (e.kind === 'note')
|
||||||
act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note');
|
act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note');
|
||||||
else act('i-key', verb + 'password', 'pwd');
|
else act('i-key', verb + 'password', 'pwd');
|
||||||
if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp');
|
if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp');
|
||||||
@@ -162,6 +255,8 @@ function quickSearchRender() {
|
|||||||
// comme des Entree dans le formulaire cible.
|
// comme des Entree dans le formulaire cible.
|
||||||
async function quickSearchPickEntry(entry, mode) {
|
async function quickSearchPickEntry(entry, mode) {
|
||||||
mode = mode || 'full';
|
mode = mode || 'full';
|
||||||
|
// Note de commandes : on choisit UNE commande, jamais la note entiere.
|
||||||
|
if (entry.template === 'commands' && mode !== 'user') return qsEnterCommands(entry);
|
||||||
|
|
||||||
if (mode === 'totp') {
|
if (mode === 'totp') {
|
||||||
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
|
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
|
||||||
@@ -559,6 +654,7 @@ function openQuickSearchModal(hideAfter, forFill) {
|
|||||||
const modal = document.getElementById('quickSearchModal');
|
const modal = document.getElementById('quickSearchModal');
|
||||||
const input = document.getElementById('quickSearchInput');
|
const input = document.getElementById('quickSearchInput');
|
||||||
modal.classList.remove('is-hidden');
|
modal.classList.remove('is-hidden');
|
||||||
|
if (qsCommands) qsLeaveCommands();
|
||||||
input.value = '';
|
input.value = '';
|
||||||
quickSearchSelected = 0;
|
quickSearchSelected = 0;
|
||||||
quickSearchHideAfter = !!hideAfter;
|
quickSearchHideAfter = !!hideAfter;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Decoupe d'une note de commandes (js/app.js parseCommandNote).
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { loadApp } = require('./harness.js');
|
||||||
|
|
||||||
|
const T = loadApp().__test;
|
||||||
|
const plain = (x) => JSON.parse(JSON.stringify(x));
|
||||||
|
|
||||||
|
test('groupes "# titre", une commande par ligne, lignes vides ignorees', () => {
|
||||||
|
const r = T.parseCommandNote('# Prod\r\nssh a@1\n\n ssh a@2 \n# Base\npsql -h x\n');
|
||||||
|
assert.deepEqual(plain(r), [
|
||||||
|
{ group: 'Prod', cmd: 'ssh a@1' },
|
||||||
|
{ group: 'Prod', cmd: 'ssh a@2' },
|
||||||
|
{ group: 'Base', cmd: 'psql -h x' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commandes avant tout titre : groupe vide', () => {
|
||||||
|
assert.equal(T.parseCommandNote('ls -la\n# G\npwd')[0].group, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"## titre" et "#" seul', () => {
|
||||||
|
const r = T.parseCommandNote('## Deux\na\n#\nb');
|
||||||
|
assert.equal(r[0].group, 'Deux');
|
||||||
|
assert.equal(r[1].group, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('texte vide ou absent', () => {
|
||||||
|
assert.equal(T.parseCommandNote('').length, 0);
|
||||||
|
assert.equal(T.parseCommandNote(null).length, 0);
|
||||||
|
});
|
||||||
@@ -158,6 +158,8 @@ function loadApp(overrides = {}) {
|
|||||||
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
||||||
// historique des entrees ouvertes
|
// historique des entrees ouvertes
|
||||||
entryHistoryPush, entryHistoryTarget, ENTRY_HISTORY_MAX,
|
entryHistoryPush, entryHistoryTarget, ENTRY_HISTORY_MAX,
|
||||||
|
// note de commandes
|
||||||
|
parseCommandNote,
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user