Skip to content

Latest commit

 

History

History
242 lines (178 loc) · 14.3 KB

File metadata and controls

242 lines (178 loc) · 14.3 KB

Full-app backup

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.

How to export

  1. Open the popup (Ctrl+Space).
  2. Click the Settings tab → Backup & restore section.
  3. 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).
  4. 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" }). See docs/encryption.md for the full threat model.

How to import

  1. Open the popup → Settings tab → Backup & restore → Import….
  2. Pick a .json file 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.

File format

{
  "version": 2,
  "exported_at": 1714032615000,
  "history":            [ /* ClipEntry[]           */ ],
  "snippets":           [ /* Snippet[]             */ ],
  "snippet_categories": [ /* SnippetCategory[]     */ ],
  "notes":              [ /* Note[]                */ ],
  "totp_entries":       [ /* TotpBackupEntry[]     */ ],
  "settings":           { /* key → value           */ },
  "timesheet":            /* TimesheetBackup, opt-in */
}

Top-level fields

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 settings table IS included since v2 (it was excluded in v1) — text-expander hotkey + enabled flag, the direct hotkey→snippet slots (expander.direct_slots), the paste.plain_text_only toggle, 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.

Per-row shapes

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.

Merge semantics

Import is a merge, not a replace. Each table has its own dedup strategy:

Snippets — upsert by abbreviation

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.

History — upsert by SHA-256 hash

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_at bumps; payload stays.
  • New row → inserted, then prune_locked runs 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 — appended verbatim

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.

Versioning

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.

Editing a backup before import

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

IPC surface

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.

Capabilities

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 Export

If you fork the shells, make sure both are present.

Testing

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).

See also