Inspector Rust's Backup feature exports the complete app (history + snippets + notes + totp_entries + settings, timesheet opt-in) to a single file — optionally password-encrypted — and merges that file back on import. This is the way to:
- move your collection to a new machine,
- snapshot your state before risky edits or before importing someone else's snippets,
- share a curated set of notes/snippets with a colleague (after editing the JSON to keep just what's relevant).
Backup was introduced in v0.2.6; Settings-tab UI in v0.2.12; TOTP + settings sections and optional encryption in the backend in v0.47/v0.79. As of v0.84.237 the Settings UI always exports the whole app (the per-section tickboxes are gone), adds an opt-in Timesheet data checkbox (format v3), and exposes password encryption for export + an inline password prompt on import.
- Open the popup (
Ctrl+Space). - Click the Settings tab → Backup & restore section.
- Optionally tick Timesheet data (sessions/events/categories — can be large; bumps the file format to v3) and/or Encrypt with password (enter + repeat the password; there is no recovery without it).
- Click Export…. The native save dialog opens (NSSavePanel on macOS, Win32 SaveFileDialog on Windows). Default filename is
inspector-rust-backup-<ISO timestamp>.json. Pick a location and confirm.
The status line shows the bytes written, e.g. Exported 124.5 KB to inspector-rust-backup-2026-04-25T09-30-15.json.
Encryption note. The DB columns are AES-256-GCM encrypted at rest, but decryption happens at the export read path (so the file is portable across machines without sharing your install's key) and re-encryption happens on import (the destination machine's key protects the merged rows). An unencrypted export is therefore plaintext JSON — including 2FA secrets and (if ticked) timesheet window titles/urls. For anything sensitive tick Encrypt with password: the file becomes an AES-256-GCM envelope with an Argon2id-derived key (
{ "encrypted": true, "kdf": "argon2id", "salt", "nonce", "ciphertext" }). Seedocs/encryption.mdfor the full threat model.
- Open the popup → Settings tab → Backup & restore → Import….
- Pick a
.jsonfile in the open dialog. If the file is encrypted, an inline row asks for the password (Unlock & import; a wrong password keeps the row so you can correct it).
Import is always full-merge; whatever sections the file actually contains get merged into the live database. Empty sections in the file are no-ops.
The status line summarizes the merge:
Imported 12 notes, 8 snippets, 47 history
Imported 12 notes, 8 snippets, 47 history — note #3: ... (+2 more)
Per-row failures are collected — they don't abort the whole import.
{
"version": 2,
"exported_at": 1714032615000,
"history": [ /* ClipEntry[] */ ],
"snippets": [ /* Snippet[] */ ],
"snippet_categories": [ /* SnippetCategory[] */ ],
"notes": [ /* Note[] */ ],
"totp_entries": [ /* TotpBackupEntry[] */ ],
"settings": { /* key → value */ },
"timesheet": /* TimesheetBackup, opt-in */
}| Field | Type | Notes |
|---|---|---|
version |
u32 |
2, or 3 when the file carries the opt-in timesheet section. Bumped whenever the on-disk shape changes incompatibly. |
exported_at |
i64 |
Unix milliseconds — purely informational. |
history |
array | Full clipboard history (not paginated). ClipEntry rows. |
snippets |
array | All snippets. Snippet rows — each carrying its group by name in category. |
snippet_categories |
array | Snippet groups: { name, sort_order }. Ids are machine-local, so groups travel by name; listing them here is what makes empty groups and the ordering survive. |
notes |
array | All notes. Note rows. |
totp_entries |
array | 2FA accounts, with the base32 secret in plaintext (decrypted at export, re-encrypted with the target machine's key on import — that's what makes the file portable). |
settings |
object | App settings as key/value pairs. |
timesheet |
object | Time-tracking data. Opt-in — absent unless you ticked it, and its presence is what bumps version to 3. |
Every section has a serde default, so a file may omit any of them — that's what makes a snippets-only export (see docs/snippets-import.md) a valid backup document: the empty sections simply mean "don't touch".
The
settingstable IS included since v2 (it was excluded in v1) — text-expander hotkey + enabled flag, the direct hotkey→snippet slots (expander.direct_slots), thepaste.plain_text_onlytoggle, the seed flags, and so on. Importing a backup therefore carries your hotkeys over to the new machine; settings are upserted by key, so a value you already set locally is overwritten by the file's.
interface ClipEntry {
id: number;
content_type: "text" | "rtf" | "html" | "image" | "files";
content_text: string; // plain-text preview / search index
content_data: string; // raw payload (base64 for image, JSON array for files)
hash: string; // SHA-256 of content_type + content_data
byte_size: number;
created_at: number; // unix-millis
last_used_at: number; // unix-millis
}
interface Snippet {
id: number;
abbreviation: string; // the natural key — snippets upsert by this
title: string;
body: string;
category: string | null; // group NAME ("" = explicitly ungroup, null = leave as-is)
created_at: number;
updated_at: number;
}
interface Note {
id: number;
content_type: "text" | "rtf" | "html" | "image" | "files";
content_text: string;
content_data: string;
title: string;
category: string;
byte_size: number;
created_at: number;
updated_at: number;
}The id fields are ignored on import — SQLite assigns fresh autoincrement ids. Hashes (history) and abbreviations (snippets) are the natural keys used for dedup.
Import is a merge, not a replace. Each table has its own dedup strategy:
Same path used by the JSON snippet importer (docs/snippets-import.md). If a snippet with the same abbreviation already exists, Inspector Rust overwrites its title/body and bumps updated_at. The original created_at is preserved.
→ Re-importing the same backup is idempotent for snippets.
Groups. Every group in snippet_categories is (re)created by name first, then each snippet resolves its category name to a local id. The assignment is three-valued:
Snippet.category |
Meaning on import |
|---|---|
"AI Prompts" |
Put the snippet in that group (created if it doesn't exist) |
"" (empty string) |
Explicitly ungroup it (v0.84.262 — the signal an external editor needs) |
null / absent |
Leave an existing snippet's group untouched |
The last rule is the important one: a backup written by an older build (no groups at all) must never wipe the grouping on the machine it's restored to. Export always writes null for an ungrouped snippet — "" is import-only.
Each clipboard entry is hashed by content_type + content_data (see db::hash_payload). On import, that hash is looked up:
- Existing row → only
last_used_atbumps; payload stays. - New row → inserted, then
prune_lockedruns to enforce the 1 000-entry cap.
→ Re-importing the same backup adds nothing to history; restoring a backup into a populated database may push older entries out due to the cap, which is intentional.
Notes have no natural unique key (you may legitimately want two notes with the same title and category). On import, every note is inserted as a fresh row with the original created_at and updated_at preserved (so list ordering is stable across an export-import cycle).
→ Re-importing the same backup file doubles every note. If you want a clean replace, Clear All first, then import.
The exporter writes "version": 2 (or 3 when the timesheet is included — timesheet-less files deliberately claim the older, more compatible version). The importer:
| Backup version | Behaviour |
|---|---|
1 |
Imported (no 2FA/settings sections — they default to empty). |
2 |
Imported. |
3 |
Imported incl. timesheet (sessions dedup by start, events by (session, start, app, source), ids remapped, titles/urls re-encrypted; an active session is imported as ended). |
> 3 (newer) |
Rejected with backup version N is newer than this app supports (3). |
This protects against a newer Inspector Rust writing fields the running build doesn't understand and silently discarding them. If you hit this, upgrade Inspector Rust or hand-edit the JSON to drop unknown fields and downgrade version.
The JSON is human-readable and stable. Common surgeries with jq:
# Drop the entire history section before sharing with a colleague
jq '.history = []' inspector-rust-backup.json > inspector-rust-backup-no-history.json
# Keep only notes in category "Work"
jq '.notes |= map(select(.category == "Work"))' inspector-rust-backup.json > work-only.json
# Strip image notes (they tend to be heavy)
jq '.notes |= map(select(.content_type != "image"))' inspector-rust-backup.json > textual.json
# Merge two backup files (snippets/notes/history concatenated; ids will be re-assigned on import)
jq -s '
{
version: 1,
exported_at: (now * 1000 | floor),
history: ((.[0].history // []) + (.[1].history // [])),
snippets: ((.[0].snippets // []) + (.[1].snippets // [])),
notes: ((.[0].notes // []) + (.[1].notes // []))
}
' a.json b.json > merged.json| Command | Args | Returns |
|---|---|---|
export_backup |
include_history?, include_snippets?, include_notes? (all default true) |
string — pretty-printed JSON |
save_backup_to_file |
path, include_history?, include_snippets?, include_notes? |
usize — bytes written |
import_backup |
path |
BackupImportResult |
interface BackupExportOptions {
includeHistory?: boolean; // default true
includeSnippets?: boolean; // default true
includeNotes?: boolean; // default true
}
interface BackupImportResult {
history_imported: number;
snippets_imported: number;
notes_imported: number;
errors: string[]; // per-row, "snippet #3 (mfg): ..." etc.
}Frontend wrappers in core/frontend/src/lib/ipc.ts; backend in core/rust-lib/src/backup.rs (ExportOptions::all() / ExportOptions::default()) and core/rust-lib/src/commands.rs.
The Settings panel calls save_backup_to_file (one IPC hop = export + write) for export and import_backup after the file picker resolves for import.
The popup window's capabilities/default.json (both win/ and macos/) carries:
"dialog:allow-open", // file picker for Import
"dialog:allow-save" // file picker for ExportIf you fork the shells, make sure both are present.
The backup module has 35 unit tests (cargo test -p inspector-rust-core backup). The suite grew with the format — later cases cover encrypted backups (Argon2id envelope, wrong-password rejection), snippet versioning + categories round-tripping by name, clip-lineage id remapping, and the opt-in timesheet section. The core round-trip cases:
| Test | Asserts |
|---|---|
export_and_import_roundtrip_into_empty_db |
Export → fresh empty db → import → all rows recovered |
import_into_populated_db_merges_via_dedup |
Re-importing into the same db: history dedupes, snippets upsert, notes double |
import_rejects_newer_backup_version |
Backup with version = CURRENT + 1 → Err |
import_invalid_json_returns_err |
Malformed JSON → Err, no DB writes |
replace_all_clears_then_inserts |
The (currently un-exposed) replace_all helper truly wipes first |
clip_lineage_survives_a_backup_round_trip_with_remapped_ids |
derived_from re-points to the new ids after import |
snippet_versions_survive_the_roundtrip_and_reimport_is_a_noop |
Version merge converges; re-import changes nothing |
replace_all is implemented but not yet wired into the UI — it's the "destructive replace" path for when we want to add it later (probably gated behind an explicit checkbox in the import dialog).
docs/notes.md— Notes feature, which is included in every backup.docs/snippets-import.md— snippet-only JSON import (older, narrower scope; uses the same upsert-by-abbreviationsemantics).docs/RELEASING.md— release procedure for Inspector Rust itself.