Don't forget about ungrouped entries when selecting export groups

This commit is contained in:
Timothy Farrell 2026-05-18 02:11:05 +00:00
parent fb8df00e91
commit dc7c29b7ce
7 changed files with 466 additions and 116 deletions

View File

@ -21,7 +21,7 @@ src/
│ └── SettingsDialog.svelte # Auto-lock and tab-switch settings
├── lib/
│ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores, exportSelected(groupIds)
│ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation
│ ├── autofocus.js # Svelte action for autofocus on mount
│ └── stores/
@ -81,6 +81,11 @@ Password verification uses a test payload (random string encrypted at vault crea
- Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections.
## Export
- `exportSelected(groupIds)` replaces the old `exportAll()` — accepts an array of group IDs to export. Pass `null` or `[]` for a full export. Vault meta (salt, test payload) is always included for import decryption.
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count.
## Known Bug Fixes
- `base64ToUint8Array` was used in `db.js` `importAll()` but never imported — now imported from `crypto.js`.

View File

@ -10,7 +10,7 @@ An offline-first password manager that runs entirely in your browser. No server,
- **Full-text search** — Instant search across title, username, URL, and notes.
- **Password generator** — One-click random password generation in the entry form (🎲 button). Uses Web Crypto API for cryptographically secure randomness.
- **Copy to clipboard** — One-click copy with 15-second auto-clear.
- **JSON import/export** — Export your entire vault as encrypted JSON. Import with merge or replace mode.
- **JSON import/export** — Export your vault as encrypted JSON with selective group filtering. Import with merge or replace mode.
- **Auto-lock** — Vault locks automatically on tab switch, visibility change, or 5-minute inactivity timer.
- **Dark theme** — Responsive layout that works on desktop and mobile.

353
dist/index.html vendored
View File

@ -4513,6 +4513,15 @@ function set_value(element, value) {
}
/**
* @param {Element} element
* @param {boolean} checked
*/
function set_checked(element, checked) {
var attributes = get_attributes(element);
if (attributes.checked === (attributes.checked = checked ?? void 0)) return;
element.checked = checked;
}
/**
* @param {Element} element
* @param {string} attribute
* @param {string | null} value
* @param {boolean} [skip_warning]
@ -5578,20 +5587,35 @@ async function moveEntryToGroup(entryId, groupId) {
await db.put("entries", entry);
}
/**
* Export all data (entries + groups + meta) as a JSON object.
* Export data (entries + groups + meta) as a JSON object.
* Entries remain encrypted with the source vault's key. The import function
* requires the source vault's master password to decrypt and re-encrypt
* entries under the target vault's key.
*
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
* Include '' to export ungrouped entries.
* @returns {Promise<Object>}
*/
async function exportAll() {
async function exportSelected(groupIds) {
const db = await getDb();
const entries = await db.getAll("entries");
const groups = await db.getAll("groups");
const allEntries = await db.getAll("entries");
const allGroups = await db.getAll("groups");
const saltRow = await db.get("meta", "salt");
const testEncryptedRow = await db.get("meta", "testEncrypted");
const testPlaintextRow = await db.get("meta", "testPlaintext");
if (!groupIds || groupIds.length === 0) return {
version: DB_VERSION,
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
meta: {
salt: saltRow?.value || null,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null
},
groups: allGroups,
entries: allEntries
};
const entries = allEntries.filter((e) => groupIds.includes(e.groupId));
const groups = allGroups.filter((g) => groupIds.includes(g.id));
return {
version: DB_VERSION,
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@ -5973,9 +5997,9 @@ var search = new SearchStore();
//#region src/components/Sidebar.svelte
var root_2$5 = /* @__PURE__ */ from_html(`<div class="group-row svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span> <span class="drop-icon svelte-181dlmc">📥</span></button> <div class="group-actions svelte-181dlmc"><button class="group-action-btn svelte-181dlmc" title="Edit group">✏️</button> <button class="group-action-btn svelte-181dlmc" title="Delete group">🗑</button></div></div>`);
var root_4$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
var root_5$4 = /* @__PURE__ */ from_html(`<button></button>`);
var root_5$5 = /* @__PURE__ */ from_html(`<button></button>`);
var root_3$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group svelte-181dlmc"><label for="group-name" class="svelte-181dlmc">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal" class="svelte-181dlmc"/></div> <div class="form-group svelte-181dlmc"><span class="field-label svelte-181dlmc">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary svelte-181dlmc"> </button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root_6$4 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root_6$3 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="trash-section svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button></div> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full svelte-181dlmc">+ New Group</button></div> <!> <!></div>`);
function Sidebar($$anchor, $$props) {
push($$props, true);
@ -6151,7 +6175,7 @@ function Sidebar($$anchor, $$props) {
var div_10 = sibling(div_9, 2);
var div_11 = sibling(child(div_10), 2);
each(div_11, 21, () => GROUP_COLORS, index, ($$anchor, color) => {
var button_6 = root_5$4();
var button_6 = root_5$5();
template_effect(() => {
set_class(button_6, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc");
set_style(button_6, `background-color: ${get(color) ?? ""}`);
@ -6185,7 +6209,7 @@ function Sidebar($$anchor, $$props) {
});
var node_4 = sibling(node_2, 2);
var consequent_3 = ($$anchor) => {
var div_13 = root_6$4();
var div_13 = root_6$3();
var div_14 = child(div_13);
var p = sibling(child(div_14), 2);
var strong = sibling(child(p));
@ -6231,12 +6255,12 @@ var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Lo
var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu4"> </div>`);
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
var root_6$3 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
var root_6$2 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
var root_7$4 = /* @__PURE__ */ from_html(`<th style="width: 60px" class="svelte-13s7gu4"></th>`);
var root_9$1 = /* @__PURE__ */ from_html(`<span class="drag-handle svelte-13s7gu4" aria-hidden="true"></span>`);
var root_10$1 = /* @__PURE__ */ from_html(`<td class="svelte-13s7gu4"><button class="btn btn-ghost btn-sm restore-btn svelte-13s7gu4" title="Restore entry">↩️</button></td>`);
var root_8$2 = /* @__PURE__ */ from_html(`<tr><td class="svelte-13s7gu4"><!> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-notes truncate svelte-13s7gu4"> </span></td><!></tr>`);
var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th><th class="svelte-13s7gu4">Notes</th><!></tr></thead><tbody></tbody></table>`, 1);
var root_8$3 = /* @__PURE__ */ from_html(`<tr><td class="svelte-13s7gu4"><!> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-notes truncate svelte-13s7gu4"> </span></td><!></tr>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th><th class="svelte-13s7gu4">Notes</th><!></tr></thead><tbody></tbody></table>`, 1);
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
function EntryList($$anchor, $$props) {
push($$props, true);
@ -6319,13 +6343,13 @@ function EntryList($$anchor, $$props) {
append($$anchor, div_3);
};
var alternate = ($$anchor) => {
var fragment = root_5$3();
var fragment = root_5$4();
var div_4 = first_child(fragment);
var span = child(div_4);
var text_4 = child(span);
var node_2 = sibling(text_4);
var consequent_4 = ($$anchor) => {
var fragment_1 = root_6$3();
var fragment_1 = root_6$2();
var strong = sibling(first_child(fragment_1));
var text_5 = child(strong, true);
reset(strong);
@ -6352,7 +6376,7 @@ function EntryList($$anchor, $$props) {
reset(thead);
var tbody = sibling(thead);
each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => {
var tr_1 = root_8$2();
var tr_1 = root_8$3();
var td = child(tr_1);
var node_4 = child(td);
var consequent_6 = ($$anchor) => {
@ -6438,14 +6462,14 @@ var root_1$5 = /* @__PURE__ */ from_html(`<div class="toast svelte-dssgjx"> </di
var root_2$3 = /* @__PURE__ */ from_html(`<div class="loading svelte-dssgjx">Loading...</div>`);
var root_3$3 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-dssgjx"> </div>`);
var root_4$3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-dssgjx">Entry not found</div>`);
var root_6$2 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm">↩️ Restore</button> <button class="btn btn-danger btn-sm">🗑 Delete Forever</button>`, 1);
var root_6$1 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm">↩️ Restore</button> <button class="btn btn-danger btn-sm">🗑 Delete Forever</button>`, 1);
var root_7$3 = /* @__PURE__ */ from_html(`<button class="btn btn-ghost btn-sm">✏️ Edit</button> <button class="btn btn-danger btn-sm">🗑 Move to Trash</button>`, 1);
var root_8$1 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Username</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy username">📋</button></div></div>`);
var root_8$2 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Username</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy username">📋</button></div></div>`);
var root_9 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">URL</span> <div class="field-value svelte-dssgjx"><a target="_blank" rel="noopener noreferrer" class="svelte-dssgjx"> </a> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy URL">📋</button></div></div>`);
var root_10 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Notes</span> <div class="field-value notes svelte-dssgjx"> </div></div>`);
var root_11 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Move to Trash</h3> <p class="svelte-dssgjx">Move "<strong> </strong>" to the trash? You can restore it later.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_12 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Delete Forever</h3> <p class="svelte-dssgjx">Permanently delete "<strong> </strong>"? This cannot be undone.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_5$2 = /* @__PURE__ */ from_html(`<div class="detail-card svelte-dssgjx"><div class="detail-header svelte-dssgjx"><h2 class="svelte-dssgjx"> </h2> <div class="header-actions svelte-dssgjx"><!></div></div> <div class="detail-fields svelte-dssgjx"><!> <div class="detail-field"><span class="field-label svelte-dssgjx">Password</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy password">📋</button></div></div> <!> <!></div> <div class="detail-meta svelte-dssgjx"><span class="text-xs text-muted"> </span> <span class="text-xs text-muted"> </span></div></div> <!> <!>`, 1);
var root_5$3 = /* @__PURE__ */ from_html(`<div class="detail-card svelte-dssgjx"><div class="detail-header svelte-dssgjx"><h2 class="svelte-dssgjx"> </h2> <div class="header-actions svelte-dssgjx"><!></div></div> <div class="detail-fields svelte-dssgjx"><!> <div class="detail-field"><span class="field-label svelte-dssgjx">Password</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy password">📋</button></div></div> <!> <!></div> <div class="detail-meta svelte-dssgjx"><span class="text-xs text-muted"> </span> <span class="text-xs text-muted"> </span></div></div> <!> <!>`, 1);
var root$4 = /* @__PURE__ */ from_html(`<div class="entry-detail"><!> <!></div>`);
function EntryDetail($$anchor, $$props) {
push($$props, true);
@ -6549,7 +6573,7 @@ function EntryDetail($$anchor, $$props) {
append($$anchor, root_4$3());
};
var alternate_1 = ($$anchor) => {
var fragment = root_5$2();
var fragment = root_5$3();
var div_5 = first_child(fragment);
var div_6 = child(div_5);
var h2 = child(div_6);
@ -6558,7 +6582,7 @@ function EntryDetail($$anchor, $$props) {
var div_7 = sibling(h2, 2);
var node_2 = child(div_7);
var consequent_4 = ($$anchor) => {
var fragment_1 = root_6$2();
var fragment_1 = root_6$1();
var button = first_child(fragment_1);
var button_1 = sibling(button, 2);
delegated("click", button, () => $$props.onEdit(get(entry).id));
@ -6582,7 +6606,7 @@ function EntryDetail($$anchor, $$props) {
var div_8 = sibling(div_6, 2);
var node_3 = child(div_8);
var consequent_5 = ($$anchor) => {
var div_9 = root_8$1();
var div_9 = root_8$2();
var div_10 = sibling(child(div_9), 2);
var span = child(div_10);
var text_4 = child(span, true);
@ -6741,7 +6765,7 @@ delegate(["click"]);
//#region src/components/EntryForm.svelte
var root_1$4 = /* @__PURE__ */ from_html(`<div class="loading svelte-pafazm">Loading...</div>`);
var root_3$2 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-pafazm"> </div>`);
var root_5$1 = /* @__PURE__ */ from_html(`<div class="validation-error svelte-pafazm"> </div>`);
var root_5$2 = /* @__PURE__ */ from_html(`<div class="validation-error svelte-pafazm"> </div>`);
var root_4$2 = /* @__PURE__ */ from_html(`<div class="validation-errors svelte-pafazm"></div>`);
var root_7$2 = /* @__PURE__ */ from_html(`<option> </option>`);
var root_2$2 = /* @__PURE__ */ from_html(`<!> <form class="form-card svelte-pafazm"><!> <div class="form-group"><label for="title">Title *</label> <input id="title" type="text" placeholder="e.g. GitHub, Gmail"/></div> <div class="form-group"><label for="username">Username / Email</label> <input id="username" type="text" placeholder="username or email"/></div> <div class="form-group"><label for="password">Password *</label> <div class="password-input-group svelte-pafazm"><input id="password" placeholder="Password" class="svelte-pafazm"/> <button type="button" class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button type="button" class="btn btn-ghost btn-sm" title="Generate password">🎲</button></div></div> <div class="form-group"><label for="url">URL</label> <input id="url" type="url" placeholder="https://example.com"/></div> <div class="form-group"><label for="group">Group</label> <select id="group"><option>No group</option><!></select></div> <div class="form-group"><label for="notes">Notes</label> <textarea id="notes" placeholder="Any additional notes..."></textarea></div> <div class="form-actions svelte-pafazm"><button type="submit" class="btn btn-primary"> </button> <button type="button" class="btn btn-ghost">Cancel</button></div></form>`, 1);
@ -6776,6 +6800,9 @@ function EntryForm($$anchor, $$props) {
set(notes, entry.notes || "", true);
set(groupId, entry.groupId || "", true);
} else set(error, "Entry not found");
} else {
const active = search.activeGroupId;
set(groupId, active !== "all" && active !== "trash" ? active : "", true);
}
} catch (e) {
set(error, "Failed to load form: " + e.message);
@ -6844,7 +6871,7 @@ function EntryForm($$anchor, $$props) {
var consequent_2 = ($$anchor) => {
var div_3 = root_4$2();
each(div_3, 21, () => get(formErrors), index, ($$anchor, err) => {
var div_4 = root_5$1();
var div_4 = root_5$2();
var text_1 = child(div_4);
reset(div_4);
template_effect(() => set_text(text_1, `⚠ ${get(err) ?? ""}`));
@ -6950,17 +6977,28 @@ function EntryForm($$anchor, $$props) {
delegate(["click"]);
//#endregion
//#region src/components/ImportExport.svelte
var root_1$3 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1"><h3 class="svelte-17di1i9">Export Vault</h3> <p class="svelte-17di1i9">All entries and groups will be exported. You'll need the source vault's master password when importing into another vault.</p> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_3$1 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-17di1i9"> </div>`);
var root_4$1 = /* @__PURE__ */ from_html(`<div class="success-banner svelte-17di1i9"> <!></div>`);
var root_6$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">File loaded. Enter the <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p> <div class="form-group svelte-17di1i9"><label for="source-password" class="file-label svelte-17di1i9">Source vault password</label> <input id="source-password" type="password" placeholder="Enter source vault password" autocomplete="current-password" class="svelte-17di1i9"/></div> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div>`, 1);
var root_7$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">Select how to handle existing data:</p> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="form-group svelte-17di1i9"><label for="import-file" class="file-label svelte-17di1i9">Choose JSON file</label> <input id="import-file" type="file" accept=".json,application/json" class="svelte-17di1i9"/></div>`, 1);
var root_2$1 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Import vault data" tabindex="-1"><h3 class="svelte-17di1i9">Import Vault Data</h3> <!> <!> <div class="modal-actions svelte-17di1i9"><button class="btn btn-ghost">Close</button></div></div></div>`);
var root_2$1 = /* @__PURE__ */ from_html(`<label class="checkbox-label group-checkbox svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span class="group-color-dot svelte-17di1i9"></span> <span class="group-name"> </span></label>`);
var root_1$3 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1"><h3 class="svelte-17di1i9">Export Vault</h3> <p class="svelte-17di1i9">Select which groups to export. You'll need the source vault's master password when importing into another vault.</p> <div class="group-select-header svelte-17di1i9"><label class="checkbox-label svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span>Select all</span></label> <span class="entry-count svelte-17di1i9"> </span></div> <div class="group-select-list svelte-17di1i9"></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_4$1 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-17di1i9"> </div>`);
var root_5$1 = /* @__PURE__ */ from_html(`<div class="success-banner svelte-17di1i9"> <!></div>`);
var root_7$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">File loaded. Enter the <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p> <div class="form-group svelte-17di1i9"><label for="source-password" class="file-label svelte-17di1i9">Source vault password</label> <input id="source-password" type="password" placeholder="Enter source vault password" autocomplete="current-password" class="svelte-17di1i9"/></div> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div>`, 1);
var root_8$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">Select how to handle existing data:</p> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="form-group svelte-17di1i9"><label for="import-file" class="file-label svelte-17di1i9">Choose JSON file</label> <input id="import-file" type="file" accept=".json,application/json" class="svelte-17di1i9"/></div>`, 1);
var root_3$1 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Import vault data" tabindex="-1"><h3 class="svelte-17di1i9">Import Vault Data</h3> <!> <!> <div class="modal-actions svelte-17di1i9"><button class="btn btn-ghost">Close</button></div></div></div>`);
var root$2 = /* @__PURE__ */ from_html(`<div class="import-export"><button class="btn btn-ghost btn-sm" title="Export">📤 Export</button> <button class="btn btn-ghost btn-sm" title="Import">📥 Import</button> <!> <!></div>`);
function ImportExport($$anchor, $$props) {
push($$props, true);
const binding_group = [];
let showExport = /* @__PURE__ */ state(false);
async function openExportModal() {
set(allGroups, [{
id: "",
name: "Ungrouped",
color: "#6b7280"
}, ...(await getGroups()).filter((g) => !isTrashGroup(g.id))], true);
set(allEntries, await getEntries(), true);
set(selectedGroupIds, get(allGroups).map((g) => g.id), true);
set(showExport, true);
}
let showImport = /* @__PURE__ */ state(false);
let importMode = /* @__PURE__ */ state("merge");
let importResult = /* @__PURE__ */ state(null);
@ -6970,10 +7008,15 @@ function ImportExport($$anchor, $$props) {
let exporting = /* @__PURE__ */ state(false);
let sourcePassword = /* @__PURE__ */ state("");
let parsedFileData = /* @__PURE__ */ state(null);
let allGroups = /* @__PURE__ */ state(proxy([]));
let allEntries = /* @__PURE__ */ state(proxy([]));
let selectedGroupIds = /* @__PURE__ */ state(proxy([]));
let selectAll = /* @__PURE__ */ user_derived(() => get(allGroups).length > 0 && get(allGroups).every((g) => get(selectedGroupIds).includes(g.id)));
let exportEntryCount = /* @__PURE__ */ user_derived(() => get(allEntries).filter((e) => get(selectedGroupIds).includes(e.groupId)).length);
async function handleExport() {
set(exporting, true);
try {
set(exportData, await exportAll(), true);
set(exportData, await exportSelected(get(selectedGroupIds).length === get(allGroups).length ? null : get(selectedGroupIds)), true);
const json = JSON.stringify(get(exportData), null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
@ -6988,6 +7031,14 @@ function ImportExport($$anchor, $$props) {
}
set(exporting, false);
}
function toggleSelectAll() {
if (get(selectAll)) set(selectedGroupIds, [], true);
else set(selectedGroupIds, get(allGroups).map((g) => g.id), true);
}
function toggleGroup(groupId) {
if (get(selectedGroupIds).includes(groupId)) set(selectedGroupIds, get(selectedGroupIds).filter((id) => id !== groupId), true);
else set(selectedGroupIds, [...get(selectedGroupIds), groupId], true);
}
async function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
@ -7034,19 +7085,51 @@ function ImportExport($$anchor, $$props) {
var div_1 = root_1$3();
var div_2 = child(div_1);
var div_3 = sibling(child(div_2), 4);
var button_2 = child(div_3);
var text_1 = child(button_2, true);
var label = child(div_3);
var input = child(label);
remove_input_defaults(input);
next(2);
reset(label);
var span = sibling(label, 2);
var text_1 = child(span);
reset(span);
reset(div_3);
var div_4 = sibling(div_3, 2);
each(div_4, 21, () => get(allGroups), index, ($$anchor, group) => {
var label_1 = root_2$1();
var input_1 = child(label_1);
remove_input_defaults(input_1);
var span_1 = sibling(input_1, 2);
var span_2 = sibling(span_1, 2);
var text_2 = child(span_2, true);
reset(span_2);
reset(label_1);
template_effect(($0) => {
set_checked(input_1, $0);
set_style(span_1, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`);
set_text(text_2, get(group).name);
}, [() => get(selectedGroupIds).includes(get(group).id)]);
delegated("change", input_1, () => toggleGroup(get(group).id));
append($$anchor, label_1);
});
reset(div_4);
var div_5 = sibling(div_4, 2);
var button_2 = child(div_5);
var text_3 = child(button_2, true);
reset(button_2);
var button_3 = sibling(button_2, 2);
reset(div_3);
reset(div_5);
reset(div_2);
reset(div_1);
template_effect(() => {
button_2.disabled = get(exporting);
set_text(text_1, get(exporting) ? "Exporting..." : "📤 Export JSON");
set_checked(input, get(selectAll));
set_text(text_1, `${get(exportEntryCount) ?? ""} entries`);
button_2.disabled = get(exporting) || get(selectedGroupIds).length === 0;
set_text(text_3, get(exporting) ? "Exporting..." : "📤 Export JSON");
});
delegated("click", div_1, () => set(showExport, false));
delegated("click", div_2, (e) => e.stopPropagation());
delegated("change", input, toggleSelectAll);
delegated("click", button_2, handleExport);
delegated("click", button_3, () => set(showExport, false));
append($$anchor, div_1);
@ -7056,79 +7139,43 @@ function ImportExport($$anchor, $$props) {
});
var node_1 = sibling(node, 2);
var consequent_5 = ($$anchor) => {
var div_4 = root_2$1();
var div_5 = child(div_4);
var node_2 = sibling(child(div_5), 2);
var div_6 = root_3$1();
var div_7 = child(div_6);
var node_2 = sibling(child(div_7), 2);
var consequent_1 = ($$anchor) => {
var div_6 = root_3$1();
var text_2 = child(div_6, true);
reset(div_6);
template_effect(() => set_text(text_2, get(importError)));
append($$anchor, div_6);
var div_8 = root_4$1();
var text_4 = child(div_8, true);
reset(div_8);
template_effect(() => set_text(text_4, get(importError)));
append($$anchor, div_8);
};
if_block(node_2, ($$render) => {
if (get(importError)) $$render(consequent_1);
});
var node_3 = sibling(node_2, 2);
var consequent_3 = ($$anchor) => {
var div_7 = root_4$1();
var text_3 = child(div_7);
var node_4 = sibling(text_3);
var div_9 = root_5$1();
var text_5 = child(div_9);
var node_4 = sibling(text_5);
var consequent_2 = ($$anchor) => {
var text_4 = text();
template_effect(() => set_text(text_4, `(${get(importResult).skipped ?? ""} skipped)`));
append($$anchor, text_4);
var text_6 = text();
template_effect(() => set_text(text_6, `(${get(importResult).skipped ?? ""} skipped)`));
append($$anchor, text_6);
};
if_block(node_4, ($$render) => {
if (get(importResult).skipped > 0) $$render(consequent_2);
});
reset(div_7);
template_effect(() => set_text(text_3, `✓ Imported ${get(importResult).imported.entries ?? ""} entries and ${get(importResult).imported.groups ?? ""} groups `));
append($$anchor, div_7);
reset(div_9);
template_effect(() => set_text(text_5, `✓ Imported ${get(importResult).imported.entries ?? ""} entries and ${get(importResult).imported.groups ?? ""} groups `));
append($$anchor, div_9);
};
var consequent_4 = ($$anchor) => {
var fragment_1 = root_6$1();
var div_8 = sibling(first_child(fragment_1), 2);
var input = sibling(child(div_8), 2);
remove_input_defaults(input);
reset(div_8);
var div_9 = sibling(div_8, 2);
var label = child(div_9);
var input_1 = child(label);
remove_input_defaults(input_1);
input_1.value = input_1.__value = "merge";
next(2);
reset(label);
var label_1 = sibling(label, 2);
var input_2 = child(label_1);
var fragment_1 = root_7$1();
var div_10 = sibling(first_child(fragment_1), 2);
var input_2 = sibling(child(div_10), 2);
remove_input_defaults(input_2);
input_2.value = input_2.__value = "replace";
next(2);
reset(label_1);
reset(div_9);
var div_10 = sibling(div_9, 2);
var button_4 = child(div_10);
var text_5 = child(button_4, true);
reset(button_4);
var button_5 = sibling(button_4, 2);
reset(div_10);
template_effect(() => {
button_4.disabled = get(importing);
set_text(text_5, get(importing) ? "Importing..." : "📥 Import");
});
bind_value(input, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value));
bind_group(binding_group, [], input_1, () => get(importMode), ($$value) => set(importMode, $$value));
bind_group(binding_group, [], input_2, () => get(importMode), ($$value) => set(importMode, $$value));
delegated("click", button_4, handleImportSubmit);
delegated("click", button_5, () => {
set(parsedFileData, null);
set(sourcePassword, "");
});
append($$anchor, fragment_1);
};
var alternate = ($$anchor) => {
var fragment_2 = root_7$1();
var div_11 = sibling(first_child(fragment_2), 2);
var div_11 = sibling(div_10, 2);
var label_2 = child(div_11);
var input_3 = child(label_2);
remove_input_defaults(input_3);
@ -7143,12 +7190,48 @@ function ImportExport($$anchor, $$props) {
reset(label_3);
reset(div_11);
var div_12 = sibling(div_11, 2);
var input_5 = sibling(child(div_12), 2);
var button_4 = child(div_12);
var text_7 = child(button_4, true);
reset(button_4);
var button_5 = sibling(button_4, 2);
reset(div_12);
template_effect(() => input_5.disabled = get(importing));
template_effect(() => {
button_4.disabled = get(importing);
set_text(text_7, get(importing) ? "Importing..." : "📥 Import");
});
bind_value(input_2, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value));
bind_group(binding_group, [], input_3, () => get(importMode), ($$value) => set(importMode, $$value));
bind_group(binding_group, [], input_4, () => get(importMode), ($$value) => set(importMode, $$value));
delegated("change", input_5, handleFileSelect);
delegated("click", button_4, handleImportSubmit);
delegated("click", button_5, () => {
set(parsedFileData, null);
set(sourcePassword, "");
});
append($$anchor, fragment_1);
};
var alternate = ($$anchor) => {
var fragment_2 = root_8$1();
var div_13 = sibling(first_child(fragment_2), 2);
var label_4 = child(div_13);
var input_5 = child(label_4);
remove_input_defaults(input_5);
input_5.value = input_5.__value = "merge";
next(2);
reset(label_4);
var label_5 = sibling(label_4, 2);
var input_6 = child(label_5);
remove_input_defaults(input_6);
input_6.value = input_6.__value = "replace";
next(2);
reset(label_5);
reset(div_13);
var div_14 = sibling(div_13, 2);
var input_7 = sibling(child(div_14), 2);
reset(div_14);
template_effect(() => input_7.disabled = get(importing));
bind_group(binding_group, [], input_5, () => get(importMode), ($$value) => set(importMode, $$value));
bind_group(binding_group, [], input_6, () => get(importMode), ($$value) => set(importMode, $$value));
delegated("change", input_7, handleFileSelect);
append($$anchor, fragment_2);
};
if_block(node_3, ($$render) => {
@ -7156,25 +7239,25 @@ function ImportExport($$anchor, $$props) {
else if (get(parsedFileData)) $$render(consequent_4, 1);
else $$render(alternate, -1);
});
var div_13 = sibling(node_3, 2);
var button_6 = child(div_13);
reset(div_13);
reset(div_5);
reset(div_4);
delegated("click", div_4, () => set(showImport, false));
delegated("click", div_5, (e) => e.stopPropagation());
var div_15 = sibling(node_3, 2);
var button_6 = child(div_15);
reset(div_15);
reset(div_7);
reset(div_6);
delegated("click", div_6, () => set(showImport, false));
delegated("click", div_7, (e) => e.stopPropagation());
delegated("click", button_6, () => {
set(showImport, false);
set(importResult, null);
set(importError, "");
});
append($$anchor, div_4);
append($$anchor, div_6);
};
if_block(node_1, ($$render) => {
if (get(showImport)) $$render(consequent_5);
});
reset(div);
delegated("click", button, () => set(showExport, true));
delegated("click", button, openExportModal);
delegated("click", button_1, () => set(showImport, true));
append($$anchor, div);
pop();
@ -8434,6 +8517,70 @@ label {
border-color: var(--color-primary);
}
/* Group selection for export */
.group-select-header.svelte-17di1i9 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
margin-bottom: 4px;
}
.group-select-header.svelte-17di1i9 .entry-count:where(.svelte-17di1i9) {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.group-select-list.svelte-17di1i9 {
max-height: 240px;
overflow-y: auto;
margin-bottom: 8px;
}
.group-select-list.svelte-17di1i9::-webkit-scrollbar {
width: 6px;
}
.group-select-list.svelte-17di1i9::-webkit-scrollbar-track {
background: transparent;
}
.group-select-list.svelte-17di1i9::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.checkbox-label.svelte-17di1i9 {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.85rem;
}
.checkbox-label.svelte-17di1i9 input[type="checkbox"]:where(.svelte-17di1i9) {
accent-color: var(--color-primary);
cursor: pointer;
}
.group-checkbox.svelte-17di1i9 {
padding: 6px 12px;
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-checkbox.svelte-17di1i9:hover {
background: var(--color-surface-hover);
}
.group-color-dot.svelte-17di1i9 {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.settings-panel.svelte-1koizbb {
max-width: 500px;
}

View File

@ -4,6 +4,7 @@
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let { entryId, onSave, onCancel } = $props()
@ -39,6 +40,10 @@
} else {
error = 'Entry not found'
}
} else {
// Default to the currently active group if it's a real group
const active = searchStore.activeGroupId
groupId = (active !== 'all' && active !== 'trash') ? active : ''
}
} catch (e) {
error = 'Failed to load form: ' + e.message

View File

@ -1,9 +1,18 @@
<script>
import { exportAll, importAll } from '../lib/storage/db.js'
import { exportSelected, importAll, getGroups, getEntries } from '../lib/storage/db.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
let showExport = $state(false)
async function openExportModal() {
const groups = (await getGroups()).filter(g => !isTrashGroup(g.id))
allGroups = [{ id: '', name: 'Ungrouped', color: '#6b7280' }, ...groups]
allEntries = await getEntries()
selectedGroupIds = allGroups.map(g => g.id)
showExport = true
}
let showImport = $state(false)
let importMode = $state('merge') // 'merge' or 'replace'
let importResult = $state(null)
@ -14,10 +23,21 @@
let sourcePassword = $state('')
let parsedFileData = $state(null)
// Group selection for export
let allGroups = $state([])
let allEntries = $state([])
let selectedGroupIds = $state([])
let selectAll = $derived(
allGroups.length > 0 && allGroups.every(g => selectedGroupIds.includes(g.id))
)
let exportEntryCount = $derived(
allEntries.filter(e => selectedGroupIds.includes(e.groupId)).length
)
async function handleExport() {
exporting = true
try {
exportData = await exportAll()
exportData = await exportSelected(selectedGroupIds.length === allGroups.length ? null : selectedGroupIds)
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@ -33,6 +53,22 @@
exporting = false
}
function toggleSelectAll() {
if (selectAll) {
selectedGroupIds = []
} else {
selectedGroupIds = allGroups.map(g => g.id)
}
}
function toggleGroup(groupId) {
if (selectedGroupIds.includes(groupId)) {
selectedGroupIds = selectedGroupIds.filter(id => id !== groupId)
} else {
selectedGroupIds = [...selectedGroupIds, groupId]
}
}
async function handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
@ -86,7 +122,7 @@
<div class="import-export">
<!-- Export button -->
<button class="btn btn-ghost btn-sm" onclick={() => showExport = true} title="Export">
<button class="btn btn-ghost btn-sm" onclick={openExportModal} title="Export">
📤 Export
</button>
@ -101,9 +137,32 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Export Vault</h3>
<p>All entries and groups will be exported. You'll need the source vault's master password when importing into another vault.</p>
<p>Select which groups to export. You'll need the source vault's master password when importing into another vault.</p>
<div class="group-select-header">
<label class="checkbox-label">
<input type="checkbox" checked={selectAll} onchange={toggleSelectAll} />
<span>Select all</span>
</label>
<span class="entry-count">{exportEntryCount} entries</span>
</div>
<div class="group-select-list">
{#each allGroups as group}
<label class="checkbox-label group-checkbox">
<input
type="checkbox"
checked={selectedGroupIds.includes(group.id)}
onchange={() => toggleGroup(group.id)}
/>
<span class="group-color-dot" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</span>
</label>
{/each}
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting}>
<button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}>
{exporting ? 'Exporting...' : '📤 Export JSON'}
</button>
<button class="btn btn-ghost" onclick={() => showExport = false}>Cancel</button>
@ -320,4 +379,68 @@
outline: none;
border-color: var(--color-primary);
}
/* Group selection for export */
.group-select-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
margin-bottom: 4px;
}
.group-select-header .entry-count {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.group-select-list {
max-height: 240px;
overflow-y: auto;
margin-bottom: 8px;
}
.group-select-list::-webkit-scrollbar {
width: 6px;
}
.group-select-list::-webkit-scrollbar-track {
background: transparent;
}
.group-select-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.85rem;
}
.checkbox-label input[type="checkbox"] {
accent-color: var(--color-primary);
cursor: pointer;
}
.group-checkbox {
padding: 6px 12px;
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-checkbox:hover {
background: var(--color-surface-hover);
}
.group-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View File

@ -376,22 +376,42 @@ export async function moveEntryToGroup(entryId, groupId) {
// ========================
/**
* Export all data (entries + groups + meta) as a JSON object.
* Export data (entries + groups + meta) as a JSON object.
* Entries remain encrypted with the source vault's key. The import function
* requires the source vault's master password to decrypt and re-encrypt
* entries under the target vault's key.
*
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
* Include '' to export ungrouped entries.
* @returns {Promise<Object>}
*/
export async function exportAll() {
export async function exportSelected(groupIds) {
const db = await getDb()
const entries = await db.getAll('entries')
const groups = await db.getAll('groups')
const allEntries = await db.getAll('entries')
const allGroups = await db.getAll('groups')
const saltRow = await db.get('meta', 'salt')
const testEncryptedRow = await db.get('meta', 'testEncrypted')
const testPlaintextRow = await db.get('meta', 'testPlaintext')
// If no groups selected, export everything
if (!groupIds || groupIds.length === 0) {
return {
version: DB_VERSION,
exportedAt: new Date().toISOString(),
meta: {
salt: saltRow?.value || null,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups: allGroups,
entries: allEntries,
}
}
const entries = allEntries.filter(e => groupIds.includes(e.groupId))
const groups = allGroups.filter(g => groupIds.includes(g.id))
return {
version: DB_VERSION,
exportedAt: new Date().toISOString(),

View File

@ -21,7 +21,7 @@ import {
moveToTrash,
emptyTrash,
restoreEntry,
exportAll,
exportSelected,
importAll,
TRASH_GROUP_ID,
} from '../../../src/lib/storage/db.js'
@ -446,7 +446,7 @@ describe('Export / Import', () => {
})
await addEntry(entry)
exportData = await exportAll()
exportData = await exportSelected(null)
})
it('should export all data', async () => {
@ -461,6 +461,56 @@ describe('Export / Import', () => {
expect(exportData.entries[0].title).toBe('GitHub')
})
it('should export with specific group IDs', async () => {
const group2 = createGroup('Personal')
await addGroup(group2)
const enc = await encrypt('personal-secret', await deriveKey('test', generateSalt()))
await addEntry(createEntry({
title: 'Netflix',
encryptedPassword: enc,
groupId: group2.id,
}))
// Export only the Work group
const workGroupId = exportData.groups.find(g => g.name === 'Work').id
const workOnly = await exportSelected([workGroupId])
expect(workOnly.groups).toHaveLength(1)
expect(workOnly.groups[0].name).toBe('Work')
expect(workOnly.entries).toHaveLength(1)
expect(workOnly.entries[0].title).toBe('GitHub')
// Export both groups
const allGroups = await getGroups()
const bothGroupIds = [workGroupId, group2.id]
const both = await exportSelected(bothGroupIds)
expect(both.entries).toHaveLength(2)
expect(both.groups).toHaveLength(2)
})
it('should export with empty array (full export)', async () => {
const emptyExport = await exportSelected([])
expect(emptyExport.entries).toHaveLength(1)
const nonTrashGroups = emptyExport.groups.filter(g => g.id !== TRASH_GROUP_ID)
expect(nonTrashGroups).toHaveLength(1)
})
it('should export only ungrouped entries with empty string groupId', async () => {
const enc = await encrypt('ungrouped-secret', await deriveKey('test', generateSalt()))
await addEntry(createEntry({
title: 'Ungrouped Entry',
encryptedPassword: enc,
groupId: '',
}))
const ungroupedExport = await exportSelected([''])
const ungroupedEntries = ungroupedExport.entries.filter(e => e.groupId === '')
expect(ungroupedEntries).toHaveLength(1)
expect(ungroupedEntries[0].title).toBe('Ungrouped Entry')
// Should not include the Work group's entry
expect(ungroupedExport.entries).not.toEqual(expect.arrayContaining([expect.objectContaining({ title: 'GitHub' })]))
})
it('should import with merge mode', async () => {
await clearAllData()