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:
parent
fb1e92985c
commit
8ef32e6df6
7 changed files with 786 additions and 3 deletions
139
frontend/app.js
139
frontend/app.js
|
|
@ -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} <strong>Blobs:</strong> ${s.blob_count}</div>
|
||||
<div><strong>Has key:</strong> ${s.has_identity_key ? 'Yes' : 'No'} <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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue