Export/Import: ZIP export with scope selection, import with public post merge

Export (export.rs): ZIP archive with auto-chunking at 4GB. Four scopes:
identity only, posts only, posts+identity, everything (posts+key+follows+
profiles+settings). Includes blobs. Manifest JSON tracks metadata.

Import (import.rs): Read ZIP summary without importing (preview).
Import public posts into current identity with new PostIds + original
timestamps. Import as new identity (creates identity subdir from key).
Uses spawn_blocking for ZIP I/O to avoid Send issues with ZipArchive.

Tauri IPC: export_data, import_summary, import_public_posts,
import_as_new_identity commands. IdentityManager.base_dir() getter.

Frontend: Export wizard lightbox with scope radio buttons + output dir.
Import wizard with ZIP path, preview summary, action selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-31 20:56:03 -04:00
parent fb1e92985c
commit 8ef32e6df6
7 changed files with 786 additions and 3 deletions

View file

@ -3310,9 +3310,142 @@ $('#import-identity-btn').addEventListener('click', () => {
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// Export/Import buttons (placeholder — will be implemented in Phase 2/3)
$('#export-btn').addEventListener('click', () => { toast('Export coming soon'); });
$('#import-btn').addEventListener('click', () => { toast('Import coming soon'); });
// Export wizard
$('#export-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Export Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Choose what to include in the export ZIP.</p>
<div style="display:flex;flex-direction:column;gap:0.5rem;text-align:left;margin-bottom:1rem">
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Identity key only (tiny backup)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media (no key safe to share)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + identity key</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, key, follows, settings)</label>
</div>
<div style="margin-bottom:0.75rem">
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
<input id="export-output-dir" type="text" value="Downloads" style="width:100%;margin-top:0.25rem;font-size:0.8rem" />
<p style="font-size:0.65rem;color:#555;margin-top:0.2rem">Relative to your home directory, or absolute path</p>
</div>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="export-go">Export</button>
<button class="btn btn-ghost btn-sm" id="export-cancel">Cancel</button>
</div>
<div id="export-status" style="margin-top:0.5rem;font-size:0.75rem;color:#888"></div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#export-go').addEventListener('click', async () => {
const scope = overlay.querySelector('input[name="export-scope"]:checked')?.value;
if (!scope) { toast('Select a scope'); return; }
let outputDir = overlay.querySelector('#export-output-dir').value.trim() || 'Downloads';
// Resolve relative to home
if (!outputDir.startsWith('/')) {
// On desktop, use a reasonable default
outputDir = outputDir;
}
const status = overlay.querySelector('#export-status');
status.textContent = 'Exporting...';
overlay.querySelector('#export-go').disabled = true;
try {
const result = await invoke('export_data', { scope, outputDir });
status.textContent = result;
toast('Export complete!');
} catch (e) {
status.textContent = 'Error: ' + e;
toast('Export failed: ' + e);
} finally {
overlay.querySelector('#export-go').disabled = false;
}
});
overlay.querySelector('#export-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// Import wizard
$('#import-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Enter the path to an ItsGoin export ZIP file.</p>
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="width:100%;margin-bottom:0.75rem;font-size:0.8rem" />
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as new identity (requires key in export)</label>
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" checked /> Import public posts into current identity</label>
</div>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-ghost btn-sm" id="import-preview">Preview</button>
<button class="btn btn-primary btn-sm" id="import-go" style="display:none">Import</button>
<button class="btn btn-ghost btn-sm" id="import-cancel">Cancel</button>
</div>
<div id="import-status" style="margin-top:0.5rem;font-size:0.75rem;color:#888"></div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#import-preview').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
if (!zipPath) { toast('Enter a ZIP path'); return; }
const status = overlay.querySelector('#import-status');
status.textContent = 'Reading...';
try {
const summaryJson = await invoke('import_summary', { zipPath });
const s = JSON.parse(summaryJson);
const box_ = overlay.querySelector('#import-summary-box');
box_.style.display = 'block';
box_.innerHTML = `
<div><strong>Node:</strong> ${s.node_id.substring(0, 16)}...</div>
<div><strong>Posts:</strong> ${s.post_count} &nbsp; <strong>Blobs:</strong> ${s.blob_count}</div>
<div><strong>Has key:</strong> ${s.has_identity_key ? 'Yes' : 'No'} &nbsp; <strong>Follows:</strong> ${s.has_follows ? 'Yes' : 'No'}</div>
<div><strong>Exported:</strong> ${new Date(s.export_date).toLocaleDateString()}</div>`;
overlay.querySelector('#import-action-box').style.display = 'block';
overlay.querySelector('#import-go').style.display = '';
// Hide "add as identity" if no key in export
if (!s.has_identity_key) {
const addIdRadio = overlay.querySelector('input[value="add_identity"]');
addIdRadio.disabled = true;
addIdRadio.parentElement.style.opacity = '0.4';
}
status.textContent = '';
} catch (e) {
status.textContent = 'Error: ' + e;
}
});
overlay.querySelector('#import-go').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
const action = overlay.querySelector('input[name="import-action"]:checked')?.value;
if (!action) { toast('Select an import action'); return; }
const status = overlay.querySelector('#import-status');
status.textContent = 'Importing...';
overlay.querySelector('#import-go').disabled = true;
try {
let result;
if (action === 'add_identity') {
result = await invoke('import_as_new_identity', { zipPath });
} else {
result = await invoke('import_public_posts', { zipPath });
}
status.textContent = result;
toast('Import complete!');
loadFeed(true);
} catch (e) {
status.textContent = 'Error: ' + e;
toast('Import failed: ' + e);
} finally {
overlay.querySelector('#import-go').disabled = false;
}
});
overlay.querySelector('#import-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
$('#notifications-btn').addEventListener('click', async () => {
// Load current settings