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:
@@ -143,14 +143,14 @@ const Bridge = (() => {
|
||||
// back to the tray AFTER SendInput completes — necessary for the
|
||||
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
|
||||
// Win10/11 anti-focus-stealing rules block the target).
|
||||
executeAutofill(username, password, hideAfter, field) {
|
||||
executeAutofill(username, password, hideAfter, field, noClear) {
|
||||
if (!active) return;
|
||||
// field='user' → type only the username (no Tab / password).
|
||||
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
|
||||
'&password=' + encodeURIComponent(password) +
|
||||
(hideAfter ? '&hide_after=1' : '') +
|
||||
(field === 'user' ? '&field=user' : '') +
|
||||
(state.autofillClearField ? '' : '&clear=0') +
|
||||
(state.autofillClearField && !noClear ? '' : '&clear=0') +
|
||||
(state.autofillFailBalloon ? '' : '¬ify=0'));
|
||||
},
|
||||
|
||||
@@ -2898,6 +2898,7 @@ function templateLabel(tpl) {
|
||||
case 'ssh-key': return 'SSH key';
|
||||
case 'server': return 'Server';
|
||||
case 'recovery-codes': return 'Recovery codes';
|
||||
case 'commands': return 'Commands';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -4015,6 +4016,14 @@ const ENTRY_TEMPLATES = {
|
||||
{ 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': {
|
||||
kind: 'note',
|
||||
title: 'Recovery codes',
|
||||
@@ -4026,9 +4035,21 @@ const ENTRY_TEMPLATES = {
|
||||
},
|
||||
};
|
||||
|
||||
// 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.
|
||||
// DECOUPE D'UNE NOTE DE COMMANDES. "# titre" ouvre un groupe, toute autre ligne
|
||||
// non vide est une commande, les lignes vides sont ignorees. Rend
|
||||
// [{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
|
||||
// pures sur {ids, pos} : testables sans DOM (tests/history.test.js).
|
||||
const ENTRY_HISTORY_MAX = 50;
|
||||
@@ -4068,6 +4089,9 @@ function renderEntryHistoryNav() {
|
||||
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) {
|
||||
opts = opts || {};
|
||||
// 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
|
||||
// on Save so the card/table can label it correctly.
|
||||
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
|
||||
originalTemplate: isNew ? '' : (e.template || ''),
|
||||
// Attachments staged BEFORE the entry exists server-side. Empty on
|
||||
// edit (existing attachments are fetched live via the dedicated
|
||||
// /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.
|
||||
// No icon (covered by sidebar icon), no site/user/totp.
|
||||
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
|
||||
body.appendChild(soCommandsField());
|
||||
body.appendChild(soNoteBodyField(plain));
|
||||
const hist = soHistoryButton();
|
||||
if (hist) body.appendChild(hist);
|
||||
@@ -4613,6 +4639,66 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
|
||||
// Multiline note body. Maps to soState.original.password and the
|
||||
// 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) {
|
||||
const wrap = el('div', { class: 'slideover-field' });
|
||||
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
|
||||
@@ -5093,6 +5179,9 @@ function isSoDirty() {
|
||||
tags: soState.tags.join(','),
|
||||
};
|
||||
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) {
|
||||
const orig = soState.original || {};
|
||||
const staged = (soState.pendingAttachments || []).length > 0;
|
||||
@@ -8824,6 +8913,7 @@ async function init() {
|
||||
// document-level handlers, which now see "no modal open"
|
||||
// (we just closed it) and also close the dirty slideover.
|
||||
e.stopPropagation();
|
||||
if (qsCommands) { qsLeaveCommands(); return; } // liste -> entrees
|
||||
closeQuickSearchModal();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
@@ -8835,6 +8925,11 @@ async function init() {
|
||||
e.preventDefault();
|
||||
const sel = rows[quickSearchSelected];
|
||||
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 entry = state.entries.find(x => x.id === id);
|
||||
// Shift+Enter → full (user + Tab + password), Ctrl+Enter →
|
||||
|
||||
Reference in New Issue
Block a user