diff --git a/js/app.js b/js/app.js index 2272c14..ab82c8f 100644 --- a/js/app.js +++ b/js/app.js @@ -2197,6 +2197,7 @@ async function addTagToEntry(id, tag) { })), }); e.tags = tags.join(','); + editorAbsorbChange(id, { tag }); render(); toast('Tagged "' + tag + '"'); } catch (err) { toast(err.message, 'error'); } @@ -2737,7 +2738,18 @@ function buildKebabMenu(entry) { const menu = el('div', { class: 'entry-kebab-menu' }); const isNote = entry.kind === 'note'; - const items = [ + // A trashed row can only be restored or purged — pin/favourite/edit/ + // duplicate/trash all assume a live entry. The card and list views swap + // their buttons for restore+purge already; the table view reaches this + // same menu in trash, so the swap has to happen here too. + const items = state.view === 'trash' ? [ + { lbl: 'Restore', ic: 'i-rotate-ccw', + fn: () => restoreEntry(entry.id) }, + { lbl: isNote ? 'Copy note content' : 'Copy password', + ic: 'i-copy', fn: () => copyPassword(entry) }, + { lbl: 'Delete forever', ic: 'i-trash', + fn: () => permanentDelete(entry.id), danger: true }, + ] : [ { lbl: entry.pinned ? 'Unpin' : 'Pin to top', ic: 'i-pin', fn: () => togglePin(entry.id) }, { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', @@ -2745,14 +2757,16 @@ function buildKebabMenu(entry) { { lbl: isNote ? 'Copy note content' : 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, ]; - // Username is login-only — hide the menu item for notes (no username field). - if (!isNote) - items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }); - items.push( - { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, - { lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) }, - { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, - ); + if (state.view !== 'trash') { + // Username is login-only — no such field on a note. + if (!isNote) + items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }); + items.push( + { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, + { lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) }, + { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, + ); + } items.forEach(it => { const mi = el('button', { class: 'kebab-item' + (it.danger ? ' is-danger' : ''), @@ -3479,6 +3493,30 @@ function clearChecked() { renderGrid(); } +// A folder/tag change applied to an entry from OUTSIDE the editor (batch bar, +// drag-to-folder, kebab) also has to land in the open editor. Without this the +// editor still holds the pre-change payload, and its next Save re-ships it — +// silently reverting the change the user just made. +// `original` is updated alongside the working value so the dirty check (and +// the Save button's visibility) don't fire on a change the user didn't type. +function editorAbsorbChange(id, change) { + if (!soState || soState.id == null || soState.id !== id) return; + if (change.folder != null) { + // The outside action is the more recent explicit intent, so it wins + // over an unsaved folder pick in the editor. + soState.original.folder = change.folder; + const sel = $('#soFolder'); + if (sel) sel.value = change.folder; + } + if (change.tag && !soState.tags.includes(change.tag)) { + soState.tags.push(change.tag); + soState.original.tags = soState.original.tags + ? soState.original.tags + ',' + change.tag : change.tag; + renderSoChips(); + } + soDirtyCheck(); +} + async function batchMoveToFolder(folder) { const ids = Array.from(state.checked); if (!ids.length) return; @@ -3509,6 +3547,7 @@ async function batchMoveToFolder(folder) { })), }); e.folder = folder; + editorAbsorbChange(id, { folder }); } catch (err) { /* ignore individual failures */ } } toast(ids.length + ' moved to ' + folder); @@ -3546,6 +3585,7 @@ async function batchAddTag(tag) { })), }); e.tags = tags.join(','); + editorAbsorbChange(id, { tag }); } catch (err) {} } toast('Tagged ' + ids.length + ' as "' + tag + '"'); @@ -5345,7 +5385,11 @@ async function moveEntriesToFolder(ids, folder) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ + // withEncryptedMeta is mandatory on every /entries write — + // the cleartext site/username/title/tags columns are gone + // server-side, so a raw body ships nothing the server can + // store and blanks the *_enc blobs. + body: JSON.stringify(await withEncryptedMeta({ site: e.site, title: e.title || '', username: e.username, @@ -5358,9 +5402,10 @@ async function moveEntriesToFolder(ids, folder) { totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', - }), + })), }); e.folder = folder; + editorAbsorbChange(id, { folder }); ok++; } catch (err) { /* try the rest */ } } @@ -5398,6 +5443,7 @@ async function moveEntryToFolder(id, folder) { })), }); e.folder = folder; + editorAbsorbChange(id, { folder }); render(); toast('Moved to ' + folder); } catch (err) { toast(err.message, 'error'); }