-
Notifications
You must be signed in to change notification settings - Fork 0
Closed
Description
JSONL Data Export/Import System
Description
Implement JSONL file export and import functionality to allow users to backup and restore their pet data locally. This enables the "shell app" model where users own their data and can migrate between sessions.
Acceptance Criteria
- Export all pet data to JSONL files (one per pet)
- Import JSONL files to restore pet data
- Validate imported data structure
- Handle guardian settings export/import
- File download/upload UI components
- Data migration between app versions
- Privacy-first approach (no cloud storage)
Export/Import Implementation
src/lib/utils/data-export.ts
import type { Pet } from '$lib/types/Pet';
import type { Guardian } from '$lib/types/Guardian';
import { petHelpers } from '$lib/stores/pets';
import { guardianHelpers } from '$lib/stores/guardian';
import { analysisHelpers } from '$lib/stores/ai-analysis';
export interface PetDataExport {
version: string;
exportDate: string;
pet: Pet;
aiAnalyses: Record<string, any>;
}
export class DataExporter {
private static readonly CURRENT_VERSION = '1.0.0';
// Export single pet as JSONL file
static async exportPet(pet: Pet): Promise<void> {
try {
const exportData: PetDataExport = {
version: this.CURRENT_VERSION,
exportDate: new Date().toISOString(),
pet: pet,
aiAnalyses: this.getAnalysesForPet(pet.id)
};
const jsonlContent = Object.keys(exportData).map(key =>
JSON.stringify({ [key]: exportData[key as keyof PetDataExport] })
).join('\n');
this.downloadFile(jsonlContent, `${pet.name.toLowerCase().replace(/\\s+/g, '_')}.jsonl`);
} catch (error) {
throw new Error('Failed to export pet data');
}
}
// Export all data
static async exportAllData(): Promise<void> {
try {
const pets = petHelpers.getAllPets();
const guardian = guardianHelpers.getGuardian();
const exportData = {
version: this.CURRENT_VERSION,
exportDate: new Date().toISOString(),
guardian: guardian,
pets: pets,
aiAnalyses: this.getAllAnalyses()
};
const jsonlContent = Object.keys(exportData).map(key =>
JSON.stringify({ [key]: exportData[key as keyof typeof exportData] })
).join('\n');
this.downloadFile(jsonlContent, `petalytics_backup_${this.formatDate(new Date())}.jsonl`);
} catch (error) {
throw new Error('Failed to export all data');
}
}
// Import JSONL file
static async importFromFile(file: File): Promise<{ success: boolean; message: string }> {
try {
if (!file.name.endsWith('.jsonl')) {
return { success: false, message: 'Please select a valid JSONL file' };
}
const content = await this.readFileAsText(file);
const importData = this.parseJSONL(content);
const validation = this.validateImportData(importData);
if (!validation.valid) {
return { success: false, message: validation.error };
}
if (importData.pets) {
return await this.importFullBackup(importData);
} else if (importData.pet) {
return await this.importSinglePet(importData);
}
return { success: false, message: 'Invalid data format' };
} catch (error) {
return { success: false, message: 'Failed to import data: ' + error.message };
}
}
private static parseJSONL(content: string): any {
const lines = content.trim().split('\n');
let result: any = {};
for (const line of lines) {
if (line.trim()) {
const parsed = JSON.parse(line);
result = { ...result, ...parsed };
}
}
return result;
}
private static downloadFile(content: string, filename: string): void {
const blob = new Blob([content], { type: 'application/jsonl' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
private static readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
private static formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
private static getAnalysesForPet(petId: string): Record<string, any> {
// Implementation to get AI analyses for specific pet
return {};
}
private static getAllAnalyses(): Record<string, any> {
// Implementation to get all AI analyses
return {};
}
private static validateImportData(data: any): { valid: boolean; error?: string } {
if (!data.version || !data.exportDate) {
return { valid: false, error: 'Invalid file format' };
}
return { valid: true };
}
private static async importFullBackup(data: any): Promise<{ success: boolean; message: string }> {
let count = 0;
if (data.guardian) {
guardianHelpers.importGuardian(data.guardian);
}
if (data.pets?.length) {
for (const pet of data.pets) {
petHelpers.importPet(pet);
count++;
}
}
return { success: true, message: `Imported ${count} pets successfully` };
}
private static async importSinglePet(data: PetDataExport): Promise<{ success: boolean; message: string }> {
petHelpers.importPet(data.pet);
return { success: true, message: `Imported ${data.pet.name} successfully` };
}
}Export/Import UI Component
src/lib/components/ui/DataManager.svelte
<script>
import { Download, Upload, FileText, Database } from 'lucide-svelte';
import { DataExporter } from '$lib/utils/data-export';
import { petStore } from '$lib/stores/pets';
let isExporting = false;
let isImporting = false;
let importMessage = '';
let importSuccess = false;
let fileInput;
let pets = [];
petStore.subscribe(value => { pets = value; });
async function exportAllData() {
isExporting = true;
try {
await DataExporter.exportAllData();
} catch (error) {
alert('Export failed: ' + error.message);
} finally {
isExporting = false;
}
}
async function exportSinglePet(pet) {
isExporting = true;
try {
await DataExporter.exportPet(pet);
} catch (error) {
alert('Export failed: ' + error.message);
} finally {
isExporting = false;
}
}
async function handleImport(event) {
const file = event.target.files[0];
if (!file) return;
isImporting = true;
importMessage = '';
try {
const result = await DataExporter.importFromFile(file);
importMessage = result.message;
importSuccess = result.success;
if (result.success) {
// Refresh data after import
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
importMessage = 'Import failed: ' + error.message;
importSuccess = false;
} finally {
isImporting = false;
fileInput.value = '';
}
}
</script>
<div class="data-manager space-y-6">
<!-- Export Section -->
<div class="export-section">
<h3 class="text-lg font-semibold mb-4 flex items-center" style="color: var(--petalytics-text);">
<Download size={20} class="mr-2" style="color: var(--petalytics-accent);" />
Export Data
</h3>
<div class="space-y-3">
<!-- Export All -->
<div class="export-item p-4 rounded-lg border" style="background: var(--petalytics-surface); border-color: var(--petalytics-border);">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium" style="color: var(--petalytics-text);">Complete Backup</h4>
<p class="text-sm" style="color: var(--petalytics-subtle);">
Export all pets, settings, and journal entries
</p>
</div>
<button
on:click={exportAllData}
disabled={isExporting || pets.length === 0}
class="button flex items-center space-x-2"
>
{#if isExporting}
<div class="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full"></div>
{:else}
<Database size={16} />
{/if}
<span>Export All</span>
</button>
</div>
</div>
<!-- Export Individual Pets -->
{#if pets.length > 0}
<div class="individual-exports">
<h4 class="font-medium mb-2" style="color: var(--petalytics-text);">Export Individual Pets</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each pets as pet}
<div class="pet-export-item p-3 rounded-lg border" style="background: var(--petalytics-overlay); border-color: var(--petalytics-border);">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<img
src={pet.profileImageUrl || '/images/default-pet.png'}
alt={pet.name}
class="w-8 h-8 rounded-full object-cover"
/>
<div>
<p class="font-medium text-sm" style="color: var(--petalytics-text);">{pet.name}</p>
<p class="text-xs" style="color: var(--petalytics-subtle);">
{pet.journalEntries?.length || 0} entries
</p>
</div>
</div>
<button
on:click={() => exportSinglePet(pet)}
disabled={isExporting}
class="button-secondary text-xs px-2 py-1"
>
<FileText size={12} class="mr-1" />
Export
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Import Section -->
<div class="import-section">
<h3 class="text-lg font-semibold mb-4 flex items-center" style="color: var(--petalytics-text);">
<Upload size={20} class="mr-2" style="color: var(--petalytics-accent);" />
Import Data
</h3>
<div class="import-area p-6 rounded-lg border-2 border-dashed text-center"
style="border-color: var(--petalytics-border);">
<Upload size={32} style="color: var(--petalytics-subtle);" class="mx-auto mb-3" />
<p class="mb-3" style="color: var(--petalytics-text);">
Select a JSONL backup file to import
</p>
<input
bind:this={fileInput}
type="file"
accept=".jsonl"
on:change={handleImport}
class="hidden"
/>
<button
on:click={() => fileInput.click()}
disabled={isImporting}
class="button flex items-center space-x-2 mx-auto"
>
{#if isImporting}
<div class="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full"></div>
<span>Importing...</span>
{:else}
<Upload size={16} />
<span>Choose File</span>
{/if}
</button>
{#if importMessage}
<div class="mt-4 p-3 rounded"
style="background: {importSuccess ? 'var(--petalytics-pine)' : 'var(--petalytics-love)'}; opacity: 0.2;">
<p class="text-sm" style="color: {importSuccess ? 'var(--petalytics-pine)' : 'var(--petalytics-love)'};">
{importMessage}
</p>
</div>
{/if}
</div>
<!-- Data Format Info -->
<div class="format-info mt-4 p-3 rounded" style="background: var(--petalytics-overlay);">
<h4 class="font-medium text-sm mb-2" style="color: var(--petalytics-text);">Data Format</h4>
<ul class="text-xs space-y-1" style="color: var(--petalytics-subtle);">
<li>• JSONL files exported from Petalytics</li>
<li>• Individual pet files or complete backups</li>
<li>• All journal entries and AI analyses included</li>
<li>• Import will merge with existing data</li>
</ul>
</div>
</div>
</div>Integration with Guardian Panel
Add to existing GuardianPanel.svelte:
<script>
import DataManager from '../ui/DataManager.svelte';
let showDataManager = false;
</script>
<!-- Add to preferences section -->
<div class="section">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium" style="color: var(--petalytics-subtle);">Data Management</span>
<button
on:click={() => showDataManager = !showDataManager}
class="text-xs button-secondary"
>
{showDataManager ? 'Hide' : 'Show'} Export/Import
</button>
</div>
{#if showDataManager}
<DataManager />
{/if}
</div>Updated Pet Store Helpers
Add to existing src/lib/stores/pets.js:
export const petHelpers = {
// ... existing methods ...
// Get all pets
getAllPets() {
let pets = [];
petStore.subscribe(value => { pets = value; })();
return pets;
},
// Import pet data
importPet(petData) {
petStore.update(pets => {
const existingIndex = pets.findIndex(p => p.id === petData.id);
if (existingIndex >= 0) {
pets[existingIndex] = petData;
} else {
pets.push(petData);
}
this.save(pets);
return pets;
});
},
// Add journal entry to pet
addJournalEntry(petId, entry) {
petStore.update(pets => {
const pet = pets.find(p => p.id === petId);
if (pet) {
if (!pet.journalEntries) pet.journalEntries = [];
pet.journalEntries.push(entry);
this.save(pets);
}
return pets;
});
}
};Tasks
- Implement DataExporter class with JSONL export/import
- Create DataManager UI component
- Add file validation and error handling
- Integrate export/import with existing stores
- Add data migration for version compatibility
- Test with various data scenarios
- Add UI integration with GuardianPanel
Definition of Done
- Users can export individual pets or complete backup to JSONL
- JSONL files can be imported to restore data
- Data validation ensures file integrity
- UI provides clear feedback on import/export status
- Privacy-first: all data stays local to user's device
- Compatible with the "shell app" model
Copilot
Metadata
Metadata
Assignees
Labels
No labels