From bd14831449c197530396097044557e2d5445e633 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:25:50 +0100 Subject: [PATCH] feat: Bitwarden import bundle + settings search + quick-search hotkey + UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- css/style.css | 56 ++- .../Handlers/PM.Handler.Folders.pas | 7 + delphi-backend/Source/PM.Bridge.pas | 16 + delphi-backend/UMainForm.pas | 11 + delphi-backend/assets/assets.res | Bin 608500 -> 625676 bytes index.html | 17 +- js/app.js | 334 ++++++++++++++++-- 7 files changed, 405 insertions(+), 36 deletions(-) diff --git a/css/style.css b/css/style.css index 0c4c009..3dc0dab 100644 --- a/css/style.css +++ b/css/style.css @@ -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; diff --git a/delphi-backend/Handlers/PM.Handler.Folders.pas b/delphi-backend/Handlers/PM.Handler.Folders.pas index 4873ca4..d176253 100644 --- a/delphi-backend/Handlers/PM.Handler.Folders.pas +++ b/delphi-backend/Handlers/PM.Handler.Folders.pas @@ -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; diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 30363ae..ee599bb 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -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; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index a9cea6e..b68040a 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -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; diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 7ab11e69b14c4cac44d3b049b3f32392d4dde4e1..b7359cda428a99089936bb8ec604fd87feb7e83c 100644 GIT binary patch delta 12699 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 SxEPO{+?nZW>X*^)9R5EM18HLb delta 1049 zcmZ8feP~-%6zApL+;{WUu5q}mNt)YkOOq{4);edqn$R*`(avo!KQ@?iugyyum%RJ> z-V1B1r4z)e6E-Q^v24RKCd^5NEa3HXcCZz3f2=Y@DWdfQKPHo6iyu?L*H}cna1NZy zIluF}zu$e~rSRg5>yA;|qPx^^2OFEa;6ObrJvi3i9k7b3vF9c>m*4E)79AUY=dSX_ zQw~#H{1fx5D}-NH!?lM6H=N!eOu~VPPz!&p5^DHROn7GN=(r|WVdLYPSbhRVuZvai z+s|SMSlQ-;f`&rSx*#^g;WHwFSqPdv)(*-dK`}PX1YF0GcopkvO zN8#d4vCe=0?wvd0hNrxfWtjG89?J=CP!N#*9hA-l{1QRWI8_ahJ7=lhof=C;# z7TQ5>!?(lxAD}gSvJIPPq?Z+Xa;iejJ}cy`L3>%gSWu~~rmYyH8C_ADGE6T}m6?h` zbE?Ux{I3v+gf&no$W4Z#$+~PRY5I>Gak`_@&oZW>T0N?1NkfZnrctI=sAcku>F|9De+wP+0)kgN@cu3u4pTLrSJbT8NAISs z8>5vcbn1FsD;j+fHQG+8#W2s*>@xix#iFZNMb1#DY*hMrXD2=%<0}k&EeWk9oPltV z_~5M)_CdX$tb!j)*a^;iL?>^)fQxHkq@ED&{scehhI_xkdwBai-t;tl{G~+sSyMXY z=8r!m{Sx4}&PbnA_-aa`aQI~@0{$z~CCE)lK7Q+p)E44z+>p*EJWIkeE9un8Gq^-- zw=H=n`6^dQ8abG=oJb~vubbWDOyZ z@Dmpt50U_v>PT6D-Gr>Settings -
+
+ + + +
+
Appearance
@@ -610,6 +619,12 @@
+
+ + Quick search picker (find entry, fill from anywhere) + + +

Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.

diff --git a/js/app.js b/js/app.js index 5fc56d7..a2977ea 100644 --- a/js/app.js +++ b/js/app.js @@ -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, + 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; + if (kind === 'full') state.autofillHotkeyFull = 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');