Skip to content

🔄 JSONL Data Export/Import System #13

@gitcoder89431

Description

@gitcoder89431

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

  1. Implement DataExporter class with JSONL export/import
  2. Create DataManager UI component
  3. Add file validation and error handling
  4. Integrate export/import with existing stores
  5. Add data migration for version compatibility
  6. Test with various data scenarios
  7. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions