feat: Bitwarden import bundle + settings search + quick-search hotkey + UX fixes
- Bitwarden CSV import: folders auto-created server-side; notes column on login rows surfaces as a "Notes" custom field instead of polluting tags; type=card / type=identity rows now mapped to kind=note with the credit-card / identity template + card_* / identity_* columns pulled into custom_fields; `fields` column parsed (Bitwarden's "label: value\nlabel: value" lines + our own JSON shape). - Settings panel search: live filter at top of the panel, matches each .setting-row individually, hides whole section when no row matches, shows a "No matches" banner. Esc clears query (without closing Settings); Esc with empty query closes the panel. - Quick-search hotkey customizable: SetQuickSearchHotkey added to PM.Bridge; cmd://autofill/hotkeys extended with qs_mods/qs_vk (independent of the autofill enabled flag — quick-search stays armed even when autofill is off); state.quickSearchHotkey synced via settings_json; new "Quick search picker" row in Settings. - FireDAC SQLite folder POST/PUT: pre-declare ftString on color/icon params so .Clear (NULL) doesn't trip "[FireDAC][Phys][SQLite]-335 type unknown" at Prepare — was crashing the CSV-import folder auto-creation path. - Edge form-data autocomplete suppressed on slideover inputs (title, site, username, password, TOTP, note body, custom fields): autocomplete=off (new-password on secrets) + spellcheck=false. Fixes the "Informations enregistrées" dropdown popping over data after a field was edited. - closeSlideOver blurs any focused descendant before removing .is-open so an invisible focused field can't react to arrow-down / backspace after dismissal. - Slideover Esc handler upgraded to capture phase so it fires before the input's own keydown or browser-level Esc swallow on the active autocomplete popup. - Settings panel Esc closes the panel when search input is empty; search keeps the keystroke when it has a query to clear. - Discard-fantome on note open: customFields working copy and originalCustomJson now share the SAME normalized array — comparing raw plainCustom against the .map()'d working copy made notes look dirty on open. - Delete / Backspace global shortcut: batch-trash on normal views, batch perm-delete on trash view, gated on selection + no input focused + no modal up. - Toggle thumb vertical centering via top:50% + translateY(-50%); state checked uses translate(16px, -50%) to keep the centring. - Batch bar disappears after per-card restore/perm-delete/trash: state.checked.delete(id) before render for the relevant flows; state.checked.clear() before render in emptyTrash and the new moveEntriesToFolder helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+54
-2
@@ -438,10 +438,12 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px; top: 1px;
|
||||
left: 2px;
|
||||
top: 50%;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
transition: all var(--t-fast);
|
||||
}
|
||||
.toggle input:checked + .toggle-slider {
|
||||
@@ -450,7 +452,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.toggle input:checked + .toggle-slider::before {
|
||||
background: white;
|
||||
transform: translateX(16px);
|
||||
transform: translate(16px, -50%);
|
||||
}
|
||||
|
||||
/* ---- 6. APP SHELL ---------------------------------------- */
|
||||
@@ -2085,6 +2087,56 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
}
|
||||
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
.slideover-field { margin-bottom: 16px; }
|
||||
/* Settings panel search — sits below the header, above the body. Only
|
||||
present inside #settingsPanel. */
|
||||
.settings-search-wrap {
|
||||
position: relative;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.settings-search-icon {
|
||||
position: absolute; left: 26px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px; height: 14px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
.settings-search-input {
|
||||
flex: 1;
|
||||
padding: 7px 30px 7px 32px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color var(--t-fast);
|
||||
}
|
||||
.settings-search-input:focus { border-color: var(--accent); }
|
||||
.settings-search-clear {
|
||||
position: absolute; right: 22px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: transparent; border: 0; padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-faint);
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
.settings-search-clear svg { width: 12px; height: 12px; }
|
||||
.settings-search-clear:hover { color: var(--text); background: var(--bg-elev-3); }
|
||||
.settings-search-wrap.has-query .settings-search-clear { display: inline-flex; }
|
||||
/* Hidden section + per-row + no-results banner driven by JS. */
|
||||
.slideover-field.is-search-hidden,
|
||||
.setting-row.is-search-hidden { display: none; }
|
||||
.settings-no-results {
|
||||
padding: 20px;
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
.settings-no-results.is-visible { display: block; }
|
||||
.slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.slideover-field-value {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
|
||||
@@ -14,6 +14,7 @@ implementation
|
||||
uses
|
||||
System.SysUtils, System.JSON, System.NetEncoding,
|
||||
System.Generics.Collections,
|
||||
Data.DB,
|
||||
FireDAC.Comp.Client, FireDAC.Stan.Param,
|
||||
IdCustomHTTPServer,
|
||||
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit;
|
||||
@@ -111,6 +112,10 @@ begin
|
||||
'VALUES (:uid, :name, :color, :icon)';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('name').AsString := LName;
|
||||
// Pre-declare type so .Clear (NULL) doesn't leave the param
|
||||
// untyped — FireDAC SQLite rejects untyped params at Prepare.
|
||||
LQ.ParamByName('color').DataType := ftString;
|
||||
LQ.ParamByName('icon').DataType := ftString;
|
||||
if LColor = '' then LQ.ParamByName('color').Clear
|
||||
else LQ.ParamByName('color').AsString := LColor;
|
||||
if LIcon = '' then LQ.ParamByName('icon').Clear
|
||||
@@ -205,11 +210,13 @@ begin
|
||||
LQ.ParamByName('name').AsString := LName;
|
||||
if LHasColor then
|
||||
begin
|
||||
LQ.ParamByName('color').DataType := ftString;
|
||||
if LColor = '' then LQ.ParamByName('color').Clear
|
||||
else LQ.ParamByName('color').AsString := LColor;
|
||||
end;
|
||||
if LHasIcon then
|
||||
begin
|
||||
LQ.ParamByName('icon').DataType := ftString;
|
||||
if LIcon = '' then LQ.ParamByName('icon').Clear
|
||||
else LQ.ParamByName('icon').AsString := LIcon;
|
||||
end;
|
||||
|
||||
@@ -146,6 +146,9 @@ type
|
||||
// stays active.
|
||||
function SetAutofillHotkeys(AFullMods, AFullVk,
|
||||
APwdMods, APwdVk: Word): Boolean;
|
||||
// Re-register the Ctrl+Shift+Q (default) quick-search hotkey with a
|
||||
// user-chosen combo. True on success.
|
||||
function SetQuickSearchHotkey(AMods, AVk: Word): Boolean;
|
||||
// Convenience wrapper: register the historical defaults (Ctrl+Shift+L
|
||||
// and Ctrl+Shift+P). Used by the host on first start; runtime changes
|
||||
// go through SetAutofillHotkeys.
|
||||
@@ -853,6 +856,19 @@ begin
|
||||
Result := FAutofillFullActive and FAutofillPwdActive;
|
||||
end;
|
||||
|
||||
function TPMBridge.SetQuickSearchHotkey(AMods, AVk: Word): Boolean;
|
||||
begin
|
||||
if FQuickSearchHotkeyRegistered then
|
||||
begin
|
||||
UnregisterHotKey(FMsgWindow, QUICK_SEARCH_HOTKEY_ID);
|
||||
FQuickSearchHotkeyRegistered := False;
|
||||
end;
|
||||
if (AVk <> 0) and (AMods <> 0) then
|
||||
FQuickSearchHotkeyRegistered := RegisterHotKey(FMsgWindow,
|
||||
QUICK_SEARCH_HOTKEY_ID, AMods, AVk);
|
||||
Result := FQuickSearchHotkeyRegistered;
|
||||
end;
|
||||
|
||||
procedure TPMBridge.ApplyTitleBarTheme(ADark: Boolean);
|
||||
const
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
|
||||
@@ -720,6 +720,17 @@ begin
|
||||
// we register both with the supplied combos (replacing any prior).
|
||||
else if ACmd = 'autofill/hotkeys' then
|
||||
begin
|
||||
// Quick-search combo is set unconditionally (independent of the
|
||||
// autofill enabled flag) so the picker stays armed even when the
|
||||
// autofill hotkeys are turned off.
|
||||
if GetParam('qs_vk') <> '' then
|
||||
begin
|
||||
var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6));
|
||||
var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q')));
|
||||
var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk);
|
||||
LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)',
|
||||
[LQsMods, LQsVk, BoolToStr(LQsOk, True)]));
|
||||
end;
|
||||
if GetParam('enabled') <> '1' then
|
||||
begin
|
||||
FBridge.UnregisterAutofillHotkey;
|
||||
|
||||
Binary file not shown.
+16
-1
@@ -403,7 +403,16 @@
|
||||
<h3>Settings</h3>
|
||||
<button class="icon-btn" id="settingsClose"><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<div class="slideover-body">
|
||||
<div class="settings-search-wrap">
|
||||
<svg class="settings-search-icon"><use href="#i-search"/></svg>
|
||||
<input type="text" id="settingsSearch" class="settings-search-input"
|
||||
placeholder="Search settings… (Esc to clear)" />
|
||||
<button class="settings-search-clear" id="settingsSearchClear" type="button"
|
||||
title="Clear search">
|
||||
<svg><use href="#i-x"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="slideover-body" id="settingsBody">
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Appearance</div>
|
||||
<div class="setting-row">
|
||||
@@ -610,6 +619,12 @@
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span style="font-size:12px;color:var(--text-dim)">
|
||||
Quick search picker (find entry, fill from anywhere)
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingQuickSearchCombo">Ctrl+Shift+Q</button>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
|
||||
</p>
|
||||
|
||||
@@ -149,9 +149,14 @@ const Bridge = (() => {
|
||||
if (!active) return;
|
||||
const f = autofillComboToWin32(combos.full);
|
||||
const p = autofillComboToWin32(combos.password);
|
||||
let qs = '';
|
||||
if (combos.quickSearch) {
|
||||
const q = autofillComboToWin32(combos.quickSearch);
|
||||
qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk;
|
||||
}
|
||||
cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') +
|
||||
'&full_mods=' + f.mods + '&full_vk=' + f.vk +
|
||||
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk);
|
||||
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs);
|
||||
},
|
||||
|
||||
// Called by Delphi after a setAutofillHotkeys request, with true if
|
||||
@@ -532,6 +537,8 @@ const state = {
|
||||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'),
|
||||
autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') ||
|
||||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
|
||||
quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') ||
|
||||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'),
|
||||
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
|
||||
'{"folders":false,"tags":false,"tools":false}'),
|
||||
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
|
||||
@@ -4432,16 +4439,13 @@ async function openSlideOver(id, opts) {
|
||||
// Working copy of the custom-fields array — mutated in place by
|
||||
// buildCustomFieldRow handlers. The serialized JSON of this array
|
||||
// at Save time is what gets encrypted into custom_fields/iv.
|
||||
customFields: plainCustom.map(f => {
|
||||
const out = {
|
||||
label: f.label || '', value: f.value || '',
|
||||
is_secret: !!f.is_secret,
|
||||
};
|
||||
if (Array.isArray(f.options) && f.options.length > 0)
|
||||
out.options = f.options.slice();
|
||||
return out;
|
||||
}),
|
||||
originalCustomJson: JSON.stringify(plainCustom),
|
||||
// customFields + originalCustomJson are assigned just after soState
|
||||
// is constructed (see below) so both sides of the dirty check use
|
||||
// the SAME normalized shape — comparing raw plainCustom against the
|
||||
// mapped working copy would falsely fire dirty on entries whose
|
||||
// stored blob carries extra/legacy keys.
|
||||
customFields: [],
|
||||
originalCustomJson: '[]',
|
||||
originalEncrypted: isNew ? null : e.encrypted_password,
|
||||
originalIV: isNew ? null : e.iv,
|
||||
originalTotpEncrypted: isNew ? null : e.totp_secret,
|
||||
@@ -4459,6 +4463,21 @@ async function openSlideOver(id, opts) {
|
||||
pendingAttachments: [],
|
||||
};
|
||||
|
||||
// Normalize the custom-fields array ONCE — the working copy and the
|
||||
// dirty-check baseline must share the same shape, otherwise stripped
|
||||
// legacy keys (empty options[], stray metadata) make the JSON diverge
|
||||
// on open and the entry looks dirty without any user input.
|
||||
soState.customFields = plainCustom.map(f => {
|
||||
const out = {
|
||||
label: f.label || '', value: f.value || '',
|
||||
is_secret: !!f.is_secret,
|
||||
};
|
||||
if (Array.isArray(f.options) && f.options.length > 0)
|
||||
out.options = f.options.slice();
|
||||
return out;
|
||||
});
|
||||
soState.originalCustomJson = JSON.stringify(soState.customFields);
|
||||
|
||||
if (isNote) {
|
||||
// Notes: minimal layout — name + multiline body + folder + tags.
|
||||
// No icon (covered by sidebar icon), no site/user/totp.
|
||||
@@ -4603,6 +4622,7 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
const labelInput = el('input', {
|
||||
type: 'text', class: 'so-input so-custom-label',
|
||||
placeholder: 'Label (e.g. PIN, Account #)',
|
||||
autocomplete: 'off', spellcheck: 'false',
|
||||
});
|
||||
labelInput.value = field.label || '';
|
||||
labelInput.addEventListener('input', () => {
|
||||
@@ -4632,6 +4652,8 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
type: field.is_secret ? 'password' : 'text',
|
||||
class: 'so-input so-custom-value',
|
||||
placeholder: 'Value',
|
||||
autocomplete: field.is_secret ? 'new-password' : 'off',
|
||||
spellcheck: 'false',
|
||||
});
|
||||
valueInput.value = field.value || '';
|
||||
valueInput.addEventListener('input', () => {
|
||||
@@ -4711,6 +4733,7 @@ function soNoteBodyField(value) {
|
||||
class: 'so-input so-note-body',
|
||||
rows: 12,
|
||||
placeholder: 'Encrypted with your vault key. Nothing leaves your device.',
|
||||
autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
|
||||
});
|
||||
ta.value = value || '';
|
||||
wrap.appendChild(ta);
|
||||
@@ -4720,7 +4743,16 @@ function soNoteBodyField(value) {
|
||||
function soEditableField(label, id, value) {
|
||||
const wrap = el('div', { class: 'slideover-field' });
|
||||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
|
||||
const input = el('input', { type: 'text', id, value, class: 'so-input' });
|
||||
const input = el('input', {
|
||||
type: 'text', id, value, class: 'so-input',
|
||||
// Disable Edge / Chromium "saved form data" history — these
|
||||
// fields can carry titles, usernames and other identifying
|
||||
// info that shouldn't end up in the browser's autocomplete
|
||||
// dropdown (visible via arrow-down on a focused field).
|
||||
autocomplete: 'off',
|
||||
autocorrect: 'off',
|
||||
spellcheck: 'false',
|
||||
});
|
||||
input.addEventListener('keydown', soOnEnterSave);
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
@@ -4818,6 +4850,7 @@ function soPasswordField(plain) {
|
||||
const input = el('input', {
|
||||
type: 'password', id: 'soPassword', value: plain,
|
||||
class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace',
|
||||
autocomplete: 'new-password', spellcheck: 'false',
|
||||
});
|
||||
input.addEventListener('keydown', soOnEnterSave);
|
||||
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' });
|
||||
@@ -4908,6 +4941,7 @@ function soTotpField(plainSecret) {
|
||||
value: plainSecret || '',
|
||||
class: 'so-input',
|
||||
placeholder: 'Paste base32 secret or otpauth:// URI',
|
||||
autocomplete: 'new-password', spellcheck: 'false',
|
||||
on: { keydown: soOnEnterSave },
|
||||
style: 'flex:1;font-family:JetBrains Mono,monospace',
|
||||
autocomplete: 'off', spellcheck: 'false',
|
||||
@@ -5387,6 +5421,12 @@ function passwordField(plain) {
|
||||
|
||||
function closeSlideOver() {
|
||||
stopTotpTick();
|
||||
// Blur any input inside the slideover BEFORE we hide it — otherwise
|
||||
// focus lingers on an invisible field and Edge's saved-form-data
|
||||
// popup ("Informations enregistrées") can still pop on arrow-down /
|
||||
// backspace, leaking past values to the user-visible UI.
|
||||
const ae = document.activeElement;
|
||||
if (ae && $('#slideover').contains(ae) && typeof ae.blur === 'function') ae.blur();
|
||||
$('#slideover').classList.remove('is-open');
|
||||
state.selectedId = null;
|
||||
renderGrid();
|
||||
@@ -7909,7 +7949,20 @@ function parseEntriesFromCSV(text) {
|
||||
// fall back to a heuristic (empty site + non-empty notes = a note).
|
||||
const colKind = findColumn(headers, ['kind', 'type', 'item_type']);
|
||||
const colTemplate = findColumn(headers, ['template', 'subtype']);
|
||||
const colCustom = findColumn(headers, ['custom_fields', 'custom']);
|
||||
const colCustom = findColumn(headers, ['custom_fields', 'custom', 'fields']);
|
||||
// Bitwarden card columns — only used when type=card. Each maps to a
|
||||
// custom field on a credit-card-template note.
|
||||
const colCardHolder = findColumn(headers, ['card_cardholdername', 'card_holder', 'cardholder']);
|
||||
const colCardBrand = findColumn(headers, ['card_brand', 'card_type']);
|
||||
const colCardNumber = findColumn(headers, ['card_number', 'cardnumber']);
|
||||
const colCardExpM = findColumn(headers, ['card_expmonth', 'card_exp_month']);
|
||||
const colCardExpY = findColumn(headers, ['card_expyear', 'card_exp_year']);
|
||||
const colCardCode = findColumn(headers, ['card_code', 'card_cvv', 'card_cvc']);
|
||||
// Bitwarden identity columns — mapped to identity-template note.
|
||||
const colIdFirst = findColumn(headers, ['identity_firstname']);
|
||||
const colIdLast = findColumn(headers, ['identity_lastname']);
|
||||
const colIdEmail = findColumn(headers, ['identity_email']);
|
||||
const colIdPhone = findColumn(headers, ['identity_phone']);
|
||||
|
||||
if (colSite === null && colTitle === null && colUser === null)
|
||||
throw new Error('No recognizable title/url or username column in CSV header');
|
||||
@@ -7928,9 +7981,14 @@ function parseEntriesFromCSV(text) {
|
||||
// CSVs (Bitwarden/KeePass) — when site+user+pwd are all empty but
|
||||
// notes/title is set, that's a secure-note row.
|
||||
let kindRaw = (colKind !== null ? String(r[colKind] || '').toLowerCase().trim() : '');
|
||||
let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' : 'login';
|
||||
let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' :
|
||||
(kindRaw === 'card' || kindRaw === 'identity') ? 'note' : 'login';
|
||||
if (kindRaw === '' && !siteRaw && !pwd && notesRaw) kind = 'note';
|
||||
const templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
|
||||
let templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
|
||||
// Bitwarden type=card / type=identity → note kind + appropriate
|
||||
// template. The card/identity columns become custom fields below.
|
||||
if (kindRaw === 'card') templateRaw = templateRaw || 'credit-card';
|
||||
if (kindRaw === 'identity') templateRaw = templateRaw || 'identity';
|
||||
|
||||
// Notes legitimately have no site; their body is in `notes` (or in
|
||||
// `password` when round-tripping our own CSV — we wrote the body
|
||||
@@ -7943,16 +8001,52 @@ function parseEntriesFromCSV(text) {
|
||||
const raw = String(r[colCustom] || '').trim();
|
||||
if (raw) {
|
||||
try {
|
||||
// Our own export: JSON array of {label, value, is_secret}
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr))
|
||||
cf = arr.filter(f => f && typeof f === 'object' && f.label);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Bitwarden / Chrome / KeePass CSV: newline-separated
|
||||
// "label: value" lines (sometimes "label=value"). Split,
|
||||
// pick the FIRST separator only so values can contain
|
||||
// ":" or "=" without being mangled.
|
||||
raw.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
const sep = line.search(/[:=]/);
|
||||
if (sep <= 0) return;
|
||||
const label = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (label) cf.push({ label, value, is_secret: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'note') {
|
||||
const body = pwd || notesRaw;
|
||||
if (!body) { skipped++; continue; }
|
||||
// Pull Bitwarden card/identity columns into custom_fields so
|
||||
// the type=card / type=identity rows survive the import.
|
||||
const push = (label, val, is_secret) => {
|
||||
if (val) cf.push({ label, value: val, is_secret: !!is_secret });
|
||||
};
|
||||
if (kindRaw === 'card') {
|
||||
push('Cardholder', colCardHolder !== null ? String(r[colCardHolder] || '').trim() : '');
|
||||
push('Brand', colCardBrand !== null ? String(r[colCardBrand] || '').trim() : '');
|
||||
push('Number', colCardNumber !== null ? String(r[colCardNumber] || '').trim() : '', true);
|
||||
const expM = colCardExpM !== null ? String(r[colCardExpM] || '').trim() : '';
|
||||
const expY = colCardExpY !== null ? String(r[colCardExpY] || '').trim() : '';
|
||||
if (expM || expY) push('Expires', (expM && expY) ? (expM + '/' + expY) : (expM || expY));
|
||||
push('CVV', colCardCode !== null ? String(r[colCardCode] || '').trim() : '', true);
|
||||
}
|
||||
if (kindRaw === 'identity') {
|
||||
const f = colIdFirst !== null ? String(r[colIdFirst] || '').trim() : '';
|
||||
const l = colIdLast !== null ? String(r[colIdLast] || '').trim() : '';
|
||||
if (f || l) push('Name', (f && l) ? (f + ' ' + l) : (f || l));
|
||||
push('Email', colIdEmail !== null ? String(r[colIdEmail] || '').trim() : '');
|
||||
push('Phone', colIdPhone !== null ? String(r[colIdPhone] || '').trim() : '');
|
||||
}
|
||||
const body = pwd || notesRaw || ' '; // template carries data via cf
|
||||
if (!body && cf.length === 0) { skipped++; continue; }
|
||||
const tagsArr = [];
|
||||
if (colTags !== null) {
|
||||
String(r[colTags] || '').split(/[,;]/).forEach(t => {
|
||||
@@ -7981,9 +8075,9 @@ function parseEntriesFromCSV(text) {
|
||||
const title = titleRaw || '';
|
||||
if (!site || !pwd) { skipped++; continue; }
|
||||
|
||||
// Tags: combine the tags column and any free-form notes into a
|
||||
// comma-separated string. Notes often contain useful metadata we
|
||||
// don't want to drop on the floor.
|
||||
// Tags: only the explicit tags column. Free-form notes are
|
||||
// surfaced as a custom "Notes" field below — putting prose into
|
||||
// the tag chip strip turned it into noise (and lost line breaks).
|
||||
let tagsArr = [];
|
||||
if (colTags !== null) {
|
||||
String(r[colTags] || '').split(/[,;]/).forEach(t => {
|
||||
@@ -7991,9 +8085,12 @@ function parseEntriesFromCSV(text) {
|
||||
if (t) tagsArr.push(t);
|
||||
});
|
||||
}
|
||||
// Bitwarden / KeePass / Chrome login rows carry per-entry notes
|
||||
// in a `notes` column. Preserve them as a non-secret custom field
|
||||
// so the body survives round-trip without polluting tags.
|
||||
if (colNotes !== null) {
|
||||
const n = String(r[colNotes] || '').trim();
|
||||
if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
|
||||
if (n) cf.push({ label: 'Notes', value: n, is_secret: false });
|
||||
}
|
||||
|
||||
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
|
||||
@@ -8256,6 +8353,34 @@ async function doImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// CSV imports (Bitwarden / KeePass / Chrome) don't carry a
|
||||
// folders[] block — they just stamp a folder name on each row.
|
||||
// Bulk-import stores the name but never creates the folders
|
||||
// table row, so the sidebar wouldn't show the new folder.
|
||||
// Auto-create any referenced folder that doesn't exist yet.
|
||||
const referenced = new Set();
|
||||
for (const e of parsed.entries) {
|
||||
const f = (e.folder || '').trim();
|
||||
if (f && f !== 'All') referenced.add(f);
|
||||
}
|
||||
if (referenced.size > 0) {
|
||||
const localNames = new Set((state.folders || [])
|
||||
.filter(f => f && f.name).map(f => f.name));
|
||||
let createdMissing = 0;
|
||||
for (const name of referenced) {
|
||||
if (localNames.has(name)) continue;
|
||||
try {
|
||||
await api('/folders', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ name, color: '', icon: '' }),
|
||||
});
|
||||
createdMissing++;
|
||||
} catch (_) { /* duplicate or invalid — skip silently */ }
|
||||
}
|
||||
if (createdMissing > 0) await loadFolders();
|
||||
}
|
||||
|
||||
toast('Encrypting ' + parsed.entries.length + ' entries…');
|
||||
|
||||
const encrypted = [];
|
||||
@@ -8628,10 +8753,12 @@ function autofillCaptureFromEvent(e) {
|
||||
function autofillPushHotkeys() {
|
||||
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
|
||||
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
|
||||
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
|
||||
if (Bridge.active) {
|
||||
Bridge.setAutofillHotkeys(state.autofillEnabled, {
|
||||
full: state.autofillHotkeyFull,
|
||||
password: state.autofillHotkeyPwd,
|
||||
quickSearch: state.quickSearchHotkey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8863,6 +8990,7 @@ function openSettings() {
|
||||
// Hotkey capture buttons — labels reflect current combos.
|
||||
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
|
||||
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
|
||||
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
|
||||
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
|
||||
// Start-with-Windows toggle: only meaningful inside the Delphi host
|
||||
// (registry access). Hide for the PHP frontend.
|
||||
@@ -8933,11 +9061,83 @@ function openSettings() {
|
||||
}
|
||||
|
||||
$('#settingsPanel').classList.add('is-open');
|
||||
// Reset the search filter every time Settings is re-opened so the
|
||||
// user lands on the full panel, not the last filtered view.
|
||||
const si = $('#settingsSearch');
|
||||
if (si) { si.value = ''; applySettingsSearch(''); }
|
||||
}
|
||||
function closeSettings() {
|
||||
$('#settingsPanel').classList.remove('is-open');
|
||||
}
|
||||
|
||||
// Filter the settings panel by text. Matches against the label + the
|
||||
// section body so e.g. "Ctrl" finds the hotkeys section via its button
|
||||
// labels. Empty query = show everything. Adds a "No matches" hint when
|
||||
// every section is hidden.
|
||||
function applySettingsSearch(rawQuery) {
|
||||
const panel = $('#settingsPanel');
|
||||
if (!panel) return;
|
||||
const wrap = $('.settings-search-wrap');
|
||||
const q = (rawQuery || '').trim().toLowerCase();
|
||||
wrap && wrap.classList.toggle('has-query', q.length > 0);
|
||||
|
||||
const sections = panel.querySelectorAll('.slideover-body > .slideover-field');
|
||||
let totalShownRows = 0;
|
||||
|
||||
sections.forEach(sec => {
|
||||
// No query: reset everything to visible.
|
||||
if (!q) {
|
||||
sec.classList.remove('is-search-hidden');
|
||||
sec.querySelectorAll('.is-search-hidden').forEach(n =>
|
||||
n.classList.remove('is-search-hidden'));
|
||||
return;
|
||||
}
|
||||
// Section label text is part of the section's identity (e.g.
|
||||
// "Sync" or "Security") — a query that hits the label keeps the
|
||||
// whole section visible without per-row filtering.
|
||||
const labelEl = sec.querySelector('.slideover-field-label');
|
||||
const labelTxt = (labelEl ? labelEl.innerText : '').toLowerCase();
|
||||
const labelMatch = labelTxt && labelTxt.indexOf(q) >= 0;
|
||||
|
||||
// Per-row filter: each .setting-row is an individually-matchable
|
||||
// entry. Non-row children (paragraphs, button groups, hints) keep
|
||||
// their default visibility — they're context for whichever row is
|
||||
// shown, not standalone matches.
|
||||
const rows = sec.querySelectorAll(':scope > .setting-row');
|
||||
let rowMatches = 0;
|
||||
rows.forEach(row => {
|
||||
if (labelMatch) {
|
||||
row.classList.remove('is-search-hidden');
|
||||
rowMatches++;
|
||||
return;
|
||||
}
|
||||
const txt = (row.innerText || '').toLowerCase();
|
||||
const hit = txt.indexOf(q) >= 0;
|
||||
row.classList.toggle('is-search-hidden', !hit);
|
||||
if (hit) rowMatches++;
|
||||
});
|
||||
|
||||
// Section has no rows at all (button-only section like Import /
|
||||
// Recovery): match against the whole section text.
|
||||
const sectionHasRows = rows.length > 0;
|
||||
const sectionHit = labelMatch ||
|
||||
(!sectionHasRows && (sec.innerText || '').toLowerCase().indexOf(q) >= 0) ||
|
||||
rowMatches > 0;
|
||||
|
||||
sec.classList.toggle('is-search-hidden', !sectionHit);
|
||||
if (sectionHit) totalShownRows += sectionHasRows ? rowMatches : 1;
|
||||
});
|
||||
|
||||
let banner = panel.querySelector('.settings-no-results');
|
||||
if (!banner) {
|
||||
banner = el('div', { class: 'settings-no-results' },
|
||||
'No settings match your search.');
|
||||
const body = panel.querySelector('.slideover-body');
|
||||
if (body) body.appendChild(banner);
|
||||
}
|
||||
banner.classList.toggle('is-visible', q.length > 0 && totalShownRows === 0);
|
||||
}
|
||||
|
||||
// ---- Auto-lock with 30s warning countdown -------------------
|
||||
const WARNING_SECONDS = 30;
|
||||
let autoLockTimer = null;
|
||||
@@ -9747,7 +9947,7 @@ const SYNCED_SETTING_KEYS = [
|
||||
// Hotkey combos are user preferences — values are portable. The
|
||||
// registration itself is Windows-only, so non-Windows clients just
|
||||
// ignore them.
|
||||
'autofillHotkeyFull', 'autofillHotkeyPwd',
|
||||
'autofillHotkeyFull', 'autofillHotkeyPwd', 'quickSearchHotkey',
|
||||
// Sidebar section collapsed state. Object of { folders, tags, tools }
|
||||
// booleans. Synced so the user gets the same fold state across devices.
|
||||
'sidebarCollapsed',
|
||||
@@ -9811,6 +10011,7 @@ async function loadServerSettings() {
|
||||
case 'pageSize': localStorage.setItem('pageSize', String(v)); break;
|
||||
case 'autofillHotkeyFull':
|
||||
case 'autofillHotkeyPwd':
|
||||
case 'quickSearchHotkey':
|
||||
case 'sidebarCollapsed':
|
||||
// Object; persist as JSON so the next cold start picks it up.
|
||||
localStorage.setItem(k, JSON.stringify(v));
|
||||
@@ -9982,6 +10183,26 @@ async function init() {
|
||||
});
|
||||
document.addEventListener('keydown', handleCardCursorKey);
|
||||
|
||||
// Delete / Backspace on the grid (no input focused, no modal open)
|
||||
// triggers the batch action matching the current view: soft-trash for
|
||||
// normal views, permanent-delete for the trash view. Mirrors what the
|
||||
// batch bar does, just via keyboard.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const tag = (e.target && e.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
if (e.target && e.target.isContentEditable) return;
|
||||
if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return;
|
||||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||||
if ($('#slideover') && $('#slideover').classList.contains('is-open')) return;
|
||||
if ($('#settingsPanel') && $('#settingsPanel').classList.contains('is-open')) return;
|
||||
if (state.checked.size === 0) return;
|
||||
e.preventDefault();
|
||||
if (state.view === 'trash') batchPermDelete();
|
||||
else batchDelete();
|
||||
});
|
||||
|
||||
// Auth tabs
|
||||
$$('.auth-tab').forEach(t => {
|
||||
t.addEventListener('click', () => {
|
||||
@@ -10173,13 +10394,16 @@ async function init() {
|
||||
// an input and ends outside doesn't trigger close (the resulting click
|
||||
// event has a target outside the slideover even though the user never
|
||||
// intended to dismiss it).
|
||||
// Esc closes the slideover.
|
||||
// Esc closes the slideover. Registered with capture=true so it fires
|
||||
// BEFORE any input-level handler that might call stopPropagation, and
|
||||
// before the browser swallows the keystroke for things like clearing
|
||||
// an active form-autocomplete popup on a focused field.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!$('#slideover').classList.contains('is-open')) return;
|
||||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||||
requestCloseSlideOver();
|
||||
});
|
||||
}, true);
|
||||
// Click-outside closes too. mousedown origin is captured so a
|
||||
// drag-selection that starts inside an input and ends outside doesn't
|
||||
// count as an outside click. Cards/rows/modals/etc. are whitelisted
|
||||
@@ -10352,6 +10576,41 @@ async function init() {
|
||||
// Settings slide-over
|
||||
$('#settingsBtn').addEventListener('click', openSettings);
|
||||
$('#settingsClose').addEventListener('click', closeSettings);
|
||||
|
||||
// Escape closes the Settings panel — unless the search input is
|
||||
// focused with a non-empty query (in that case its own handler
|
||||
// already swallowed the event and cleared the query). Skip when a
|
||||
// confirm/reauth modal is up so its Escape stays the priority.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!$('#settingsPanel').classList.contains('is-open')) return;
|
||||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||||
closeSettings();
|
||||
});
|
||||
|
||||
// Settings search box: live filter on every input. Escape clears the
|
||||
// query (without closing the panel — the existing Esc handler also
|
||||
// closes settings, but only when focus isn't inside an input).
|
||||
const sInput = $('#settingsSearch');
|
||||
const sClear = $('#settingsSearchClear');
|
||||
if (sInput) {
|
||||
sInput.addEventListener('input', e => applySettingsSearch(e.target.value));
|
||||
sInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && sInput.value) {
|
||||
e.stopPropagation();
|
||||
sInput.value = '';
|
||||
applySettingsSearch('');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (sClear) {
|
||||
sClear.addEventListener('click', () => {
|
||||
if (!sInput) return;
|
||||
sInput.value = '';
|
||||
applySettingsSearch('');
|
||||
sInput.focus();
|
||||
});
|
||||
}
|
||||
// Theme: setTheme already writes localStorage. Capture before/after so
|
||||
// sync only fires if it actually changed.
|
||||
$('#settingTheme').addEventListener('change', e => {
|
||||
@@ -10613,16 +10872,22 @@ async function init() {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
// Reject if it collides with the other slot.
|
||||
const other = (kind === 'full') ? state.autofillHotkeyPwd : state.autofillHotkeyFull;
|
||||
if (JSON.stringify(other) === JSON.stringify(captured)) {
|
||||
toast('That combo is already used by the other hotkey', 'warning');
|
||||
// Reject if it collides with any of the other configurable slots.
|
||||
const others = [
|
||||
kind !== 'full' ? state.autofillHotkeyFull : null,
|
||||
kind !== 'password' ? state.autofillHotkeyPwd : null,
|
||||
kind !== 'quickSearch' ? state.quickSearchHotkey : null,
|
||||
].filter(Boolean);
|
||||
const capJson = JSON.stringify(captured);
|
||||
if (others.some(o => JSON.stringify(o) === capJson)) {
|
||||
toast('That combo is already used by another hotkey', 'warning');
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
// Commit.
|
||||
if (kind === 'full') state.autofillHotkeyFull = captured;
|
||||
else state.autofillHotkeyPwd = captured;
|
||||
else if (kind === 'password') state.autofillHotkeyPwd = captured;
|
||||
else /* quickSearch */ state.quickSearchHotkey = captured;
|
||||
btn.textContent = autofillComboLabel(captured);
|
||||
finish(false);
|
||||
autofillPushHotkeys(); // re-register in Delphi
|
||||
@@ -10633,12 +10898,15 @@ async function init() {
|
||||
}
|
||||
bindHotkeyCapture('#settingAutofillFullCombo', 'full');
|
||||
bindHotkeyCapture('#settingAutofillPwdCombo', 'password');
|
||||
bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch');
|
||||
|
||||
$('#settingAutofillResetHotkeys').addEventListener('click', () => {
|
||||
state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' };
|
||||
state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' };
|
||||
state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' };
|
||||
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
|
||||
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
|
||||
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
|
||||
autofillPushHotkeys();
|
||||
saveServerSettings();
|
||||
toast('Hotkeys reset to defaults');
|
||||
|
||||
Reference in New Issue
Block a user