Source

utils/projectManager.js

/**
 * @fileoverview Project management and export/import system
 * @module ProjectManager
 */

/**
 * Project configuration class
 */
export class ProjectConfig {
  constructor() {
    this.version = '2.0.0';
    this.name = 'Untitled Project';
    this.description = '';
    this.author = '';
    this.created = new Date().toISOString();
    this.modified = new Date().toISOString();
    this.tags = [];
    
    this.canvas = {
      width: 1920,
      height: 1080,
      background: '#000000',
      fps: 60
    };
    
    this.animation = {
      type: 'particleNetwork',
      duration: 10000,
      loop: true,
      autostart: true
    };
    
    this.theme = {
      name: 'gaming',
      customColors: [],
      customSettings: {}
    };
    
    this.performance = {
      particleCount: 100,
      quality: 'medium',
      enableGPU: true,
      enableAudio: false
    };
    
    this.interactions = {
      enabled: true,
      type: 'attract',
      strength: 1.0,
      radius: 100
    };
    
    this.effects = {
      bloom: false,
      blur: false,
      glow: true,
      trails: false
    };
  }

  /**
   * Update modification timestamp
   */
  touch() {
    this.modified = new Date().toISOString();
  }

  /**
   * Validate configuration
   * @returns {Object} Validation result
   */
  validate() {
    const errors = [];
    const warnings = [];
    
    // Required fields
    if (!this.name || this.name.trim() === '') {
      errors.push('Project name is required');
    }
    
    // Canvas validation
    if (this.canvas.width < 100 || this.canvas.width > 7680) {
      warnings.push('Canvas width should be between 100 and 7680 pixels');
    }
    
    if (this.canvas.height < 100 || this.canvas.height > 4320) {
      warnings.push('Canvas height should be between 100 and 4320 pixels');
    }
    
    if (this.canvas.fps < 1 || this.canvas.fps > 120) {
      warnings.push('FPS should be between 1 and 120');
    }
    
    // Performance validation
    if (this.performance.particleCount < 1 || this.performance.particleCount > 10000) {
      warnings.push('Particle count should be between 1 and 10000');
    }
    
    return {
      isValid: errors.length === 0,
      errors,
      warnings
    };
  }

  /**
   * Clone configuration
   * @returns {ProjectConfig} Cloned configuration
   */
  clone() {
    const cloned = new ProjectConfig();
    Object.assign(cloned, JSON.parse(JSON.stringify(this)));
    cloned.name = `${this.name} Copy`;
    cloned.created = new Date().toISOString();
    cloned.modified = new Date().toISOString();
    return cloned;
  }
}

/**
 * Project manager for handling multiple projects
 */
export class ProjectManager {
  constructor() {
    this.currentProject = null;
    this.projects = new Map();
    this.recentProjects = [];
    this.maxRecentProjects = 10;
    
    this.exportFormats = new Map();
    this.importParsers = new Map();
    
    this.initializeFormats();
    this.loadFromStorage();
  }

  /**
   * Initialize export/import formats
   */
  initializeFormats() {
    // JSON format
    this.exportFormats.set('json', {
      name: 'JSON Project File',
      extension: 'abp',
      mimeType: 'application/json',
      export: this.exportJSON.bind(this),
      import: this.importJSON.bind(this)
    });
    
    // Compressed format
    this.exportFormats.set('compressed', {
      name: 'Compressed Project',
      extension: 'abz',
      mimeType: 'application/octet-stream',
      export: this.exportCompressed.bind(this),
      import: this.importCompressed.bind(this)
    });
    
    // Template format
    this.exportFormats.set('template', {
      name: 'Project Template',
      extension: 'abt',
      mimeType: 'application/json',
      export: this.exportTemplate.bind(this),
      import: this.importTemplate.bind(this)
    });
    
    // Legacy format support
    this.exportFormats.set('legacy', {
      name: 'Legacy Format (v1.x)',
      extension: 'abg',
      mimeType: 'application/json',
      export: this.exportLegacy.bind(this),
      import: this.importLegacy.bind(this)
    });
  }

  /**
   * Create new project
   * @param {string} name - Project name
   * @param {Object} options - Project options
   * @returns {ProjectConfig} New project
   */
  createProject(name = 'New Project', options = {}) {
    const project = new ProjectConfig();
    project.name = name;
    Object.assign(project, options);
    
    this.currentProject = project;
    this.projects.set(project.name, project);
    this.addToRecent(project);
    
    return project;
  }

  /**
   * Load project
   * @param {string} name - Project name
   * @returns {ProjectConfig} Loaded project
   */
  loadProject(name) {
    const project = this.projects.get(name);
    if (project) {
      this.currentProject = project;
      this.addToRecent(project);
      return project;
    }
    throw new Error(`Project '${name}' not found`);
  }

  /**
   * Save current project
   */
  saveProject() {
    if (!this.currentProject) {
      throw new Error('No project to save');
    }
    
    this.currentProject.touch();
    this.projects.set(this.currentProject.name, this.currentProject);
    this.saveToStorage();
  }

  /**
   * Save project as new name
   * @param {string} newName - New project name
   */
  saveProjectAs(newName) {
    if (!this.currentProject) {
      throw new Error('No project to save');
    }
    
    const oldName = this.currentProject.name;
    this.currentProject.name = newName;
    this.currentProject.touch();
    
    this.projects.delete(oldName);
    this.projects.set(newName, this.currentProject);
    this.addToRecent(this.currentProject);
    this.saveToStorage();
  }

  /**
   * Delete project
   * @param {string} name - Project name
   */
  deleteProject(name) {
    if (this.currentProject && this.currentProject.name === name) {
      this.currentProject = null;
    }
    
    this.projects.delete(name);
    this.recentProjects = this.recentProjects.filter(p => p.name !== name);
    this.saveToStorage();
  }

  /**
   * Get all project names
   * @returns {Array} Project names
   */
  getProjectNames() {
    return Array.from(this.projects.keys());
  }

  /**
   * Get project info
   * @param {string} name - Project name
   * @returns {Object} Project info
   */
  getProjectInfo(name) {
    const project = this.projects.get(name);
    if (!project) return null;
    
    return {
      name: project.name,
      description: project.description,
      author: project.author,
      created: project.created,
      modified: project.modified,
      tags: project.tags,
      size: JSON.stringify(project).length
    };
  }

  /**
   * Search projects
   * @param {string} query - Search query
   * @returns {Array} Matching projects
   */
  searchProjects(query) {
    const lowerQuery = query.toLowerCase();
    return Array.from(this.projects.values()).filter(project => 
      project.name.toLowerCase().includes(lowerQuery) ||
      project.description.toLowerCase().includes(lowerQuery) ||
      project.tags.some(tag => tag.toLowerCase().includes(lowerQuery))
    );
  }

  /**
   * Export project to file
   * @param {string} format - Export format
   * @param {ProjectConfig} project - Project to export
   * @returns {Blob} Exported data
   */
  async exportProject(format = 'json', project = this.currentProject) {
    if (!project) {
      throw new Error('No project to export');
    }
    
    const formatter = this.exportFormats.get(format);
    if (!formatter) {
      throw new Error(`Unknown export format: ${format}`);
    }
    
    return await formatter.export(project);
  }

  /**
   * Import project from file
   * @param {File|Blob} file - File to import
   * @param {string} format - Import format (auto-detected if not provided)
   * @returns {ProjectConfig} Imported project
   */
  async importProject(file, format = null) {
    if (!format) {
      format = this.detectFormat(file);
    }
    
    const formatter = this.exportFormats.get(format);
    if (!formatter) {
      throw new Error(`Unknown import format: ${format}`);
    }
    
    const project = await formatter.import(file);
    
    // Ensure unique name
    let baseName = project.name;
    let counter = 1;
    while (this.projects.has(project.name)) {
      project.name = `${baseName} (${counter})`;
      counter++;
    }
    
    this.projects.set(project.name, project);
    this.addToRecent(project);
    this.saveToStorage();
    
    return project;
  }

  /**
   * Export to JSON format
   * @param {ProjectConfig} project - Project to export
   * @returns {Blob} JSON blob
   */
  async exportJSON(project) {
    const data = {
      ...project,
      exportedBy: 'Animated Backgrounds v2.0',
      exportedAt: new Date().toISOString()
    };
    
    const json = JSON.stringify(data, null, 2);
    return new Blob([json], { type: 'application/json' });
  }

  /**
   * Import from JSON format
   * @param {File|Blob} file - File to import
   * @returns {ProjectConfig} Imported project
   */
  async importJSON(file) {
    const text = await file.text();
    const data = JSON.parse(text);
    
    const project = new ProjectConfig();
    Object.assign(project, data);
    
    return project;
  }

  /**
   * Export to compressed format
   * @param {ProjectConfig} project - Project to export
   * @returns {Blob} Compressed blob
   */
  async exportCompressed(project) {
    const json = JSON.stringify(project);
    const compressed = this.compress(json);
    return new Blob([compressed], { type: 'application/octet-stream' });
  }

  /**
   * Import from compressed format
   * @param {File|Blob} file - File to import
   * @returns {ProjectConfig} Imported project
   */
  async importCompressed(file) {
    const buffer = await file.arrayBuffer();
    const decompressed = this.decompress(new Uint8Array(buffer));
    const data = JSON.parse(decompressed);
    
    const project = new ProjectConfig();
    Object.assign(project, data);
    
    return project;
  }

  /**
   * Export as template
   * @param {ProjectConfig} project - Project to export
   * @returns {Blob} Template blob
   */
  async exportTemplate(project) {
    const template = {
      name: project.name,
      description: project.description,
      tags: ['template', ...project.tags],
      canvas: project.canvas,
      animation: project.animation,
      theme: project.theme,
      performance: project.performance,
      interactions: project.interactions,
      effects: project.effects,
      isTemplate: true,
      templateVersion: '1.0'
    };
    
    const json = JSON.stringify(template, null, 2);
    return new Blob([json], { type: 'application/json' });
  }

  /**
   * Import template
   * @param {File|Blob} file - File to import
   * @returns {ProjectConfig} Imported project
   */
  async importTemplate(file) {
    const text = await file.text();
    const template = JSON.parse(text);
    
    const project = new ProjectConfig();
    Object.assign(project, template);
    project.name = `${template.name} Project`;
    project.created = new Date().toISOString();
    project.modified = new Date().toISOString();
    
    return project;
  }

  /**
   * Export to legacy format
   * @param {ProjectConfig} project - Project to export
   * @returns {Blob} Legacy format blob
   */
  async exportLegacy(project) {
    const legacy = {
      version: '1.0',
      settings: {
        animation: project.animation.type,
        theme: project.theme.name,
        particleCount: project.performance.particleCount,
        colors: project.theme.customColors,
        interactive: project.interactions.enabled
      }
    };
    
    const json = JSON.stringify(legacy, null, 2);
    return new Blob([json], { type: 'application/json' });
  }

  /**
   * Import from legacy format
   * @param {File|Blob} file - File to import
   * @returns {ProjectConfig} Imported project
   */
  async importLegacy(file) {
    const text = await file.text();
    const legacy = JSON.parse(text);
    
    const project = new ProjectConfig();
    project.name = 'Imported Legacy Project';
    project.animation.type = legacy.settings.animation || 'particleNetwork';
    project.theme.name = legacy.settings.theme || 'gaming';
    project.performance.particleCount = legacy.settings.particleCount || 100;
    project.interactions.enabled = legacy.settings.interactive || false;
    
    if (legacy.settings.colors) {
      project.theme.customColors = legacy.settings.colors;
    }
    
    return project;
  }

  /**
   * Detect file format
   * @param {File} file - File to analyze
   * @returns {string} Detected format
   */
  detectFormat(file) {
    const ext = file.name.split('.').pop().toLowerCase();
    
    switch (ext) {
      case 'abp': return 'json';
      case 'abz': return 'compressed';
      case 'abt': return 'template';
      case 'abg': return 'legacy';
      case 'json': return 'json';
      default: return 'json';
    }
  }

  /**
   * Add project to recent list
   * @param {ProjectConfig} project - Project to add
   */
  addToRecent(project) {
    // Remove if already exists
    this.recentProjects = this.recentProjects.filter(p => p.name !== project.name);
    
    // Add to beginning
    this.recentProjects.unshift({
      name: project.name,
      modified: project.modified,
      description: project.description
    });
    
    // Limit size
    if (this.recentProjects.length > this.maxRecentProjects) {
      this.recentProjects = this.recentProjects.slice(0, this.maxRecentProjects);
    }
  }

  /**
   * Get recent projects
   * @returns {Array} Recent projects
   */
  getRecentProjects() {
    return [...this.recentProjects];
  }

  /**
   * Save to local storage
   */
  saveToStorage() {
    try {
      const data = {
        projects: Object.fromEntries(this.projects),
        recent: this.recentProjects,
        currentProject: this.currentProject?.name || null
      };
      
      localStorage.setItem('animatedBackgrounds_projects', JSON.stringify(data));
    } catch (error) {
      console.warn('Failed to save to localStorage:', error);
    }
  }

  /**
   * Load from local storage
   */
  loadFromStorage() {
    try {
      const data = localStorage.getItem('animatedBackgrounds_projects');
      if (!data) return;
      
      const parsed = JSON.parse(data);
      
      // Load projects
      Object.entries(parsed.projects || {}).forEach(([name, projectData]) => {
        const project = new ProjectConfig();
        Object.assign(project, projectData);
        this.projects.set(name, project);
      });
      
      // Load recent projects
      this.recentProjects = parsed.recent || [];
      
      // Load current project
      if (parsed.currentProject && this.projects.has(parsed.currentProject)) {
        this.currentProject = this.projects.get(parsed.currentProject);
      }
    } catch (error) {
      console.warn('Failed to load from localStorage:', error);
    }
  }

  /**
   * Simple compression using RLE (Run Length Encoding)
   * @param {string} text - Text to compress
   * @returns {Uint8Array} Compressed data
   */
  compress(text) {
    const bytes = new TextEncoder().encode(text);
    const compressed = [];
    
    let i = 0;
    while (i < bytes.length) {
      const current = bytes[i];
      let count = 1;
      
      // Count consecutive identical bytes
      while (i + count < bytes.length && bytes[i + count] === current && count < 255) {
        count++;
      }
      
      compressed.push(count, current);
      i += count;
    }
    
    return new Uint8Array(compressed);
  }

  /**
   * Decompress RLE data
   * @param {Uint8Array} compressed - Compressed data
   * @returns {string} Decompressed text
   */
  decompress(compressed) {
    const decompressed = [];
    
    for (let i = 0; i < compressed.length; i += 2) {
      const count = compressed[i];
      const byte = compressed[i + 1];
      
      for (let j = 0; j < count; j++) {
        decompressed.push(byte);
      }
    }
    
    return new TextDecoder().decode(new Uint8Array(decompressed));
  }

  /**
   * Generate project statistics
   * @returns {Object} Project statistics
   */
  getStatistics() {
    const projects = Array.from(this.projects.values());
    
    return {
      totalProjects: projects.length,
      totalSize: projects.reduce((sum, p) => sum + JSON.stringify(p).length, 0),
      averageSize: projects.length > 0 ? 
        projects.reduce((sum, p) => sum + JSON.stringify(p).length, 0) / projects.length : 0,
      oldestProject: projects.reduce((oldest, p) => 
        !oldest || new Date(p.created) < new Date(oldest.created) ? p : oldest, null),
      newestProject: projects.reduce((newest, p) => 
        !newest || new Date(p.created) > new Date(newest.created) ? p : newest, null),
      mostRecentlyModified: projects.reduce((recent, p) => 
        !recent || new Date(p.modified) > new Date(recent.modified) ? p : recent, null),
      animationTypes: this.getAnimationTypeStats(projects),
      themes: this.getThemeStats(projects)
    };
  }

  /**
   * Get animation type statistics
   * @param {Array} projects - Projects array
   * @returns {Object} Animation type stats
   */
  getAnimationTypeStats(projects) {
    const stats = {};
    projects.forEach(project => {
      const type = project.animation.type;
      stats[type] = (stats[type] || 0) + 1;
    });
    return stats;
  }

  /**
   * Get theme statistics
   * @param {Array} projects - Projects array
   * @returns {Object} Theme stats
   */
  getThemeStats(projects) {
    const stats = {};
    projects.forEach(project => {
      const theme = project.theme.name;
      stats[theme] = (stats[theme] || 0) + 1;
    });
    return stats;
  }

  /**
   * Clear all data
   */
  clear() {
    this.projects.clear();
    this.recentProjects = [];
    this.currentProject = null;
    localStorage.removeItem('animatedBackgrounds_projects');
  }
}

// Export singleton instance
export const projectManager = new ProjectManager();