Don't forget about ungrouped entries when selecting export groups
This commit is contained in:
parent
fb8df00e91
commit
dc7c29b7ce
@ -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`.
|
||||
|
||||
@ -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
353
dist/index.html
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user