/** * Importador de projetos via JSON — M9.7 * * Lê um arquivo JSON exportado do VentoApp (roundtrip com `storage.ts`) * e atualiza o store Zustand correspondente. * * Suporta dois formatos: * 1. SavedProject (formato IndexedDB) * { name, module, inputs, createdAt, updatedAt } * 2. Snapshot direto do windStore (formato debug "Exportar estado atual") * { v0, s1, s2, s3, vk, q, ... } * * Valida estrutura mínima antes de aplicar; retorna erros tipados. */ import { useWindStore } from '../store/appStore'; import { useGalpaoStore } from '../store/galpaoStore'; import type { SavedProject } from './storage'; import type { TerrainCategory } from './wind-kernel'; export type ModuleId = SavedProject['module']; export interface ImportResult { ok: boolean; module?: ModuleId; projectName?: string; appliedFields?: string[]; warnings?: string[]; error?: string; } const VALID_MODULES: readonly ModuleId[] = [ 'galpao', 'cilindro', 'vault', 'dome', 'sign', 'isolated-roof', 'bar', 'bridge', 'dynamics', ]; const VALID_CATEGORIES: readonly TerrainCategory[] = ['I', 'II', 'III', 'IV', 'V']; function isString(v: unknown): v is string { return typeof v === 'string'; } function isNumber(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v); } function isBoolean(v: unknown): v is boolean { return typeof v === 'boolean'; } function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } /** * Detecta o tipo de arquivo importado. * * - Se tem `module` e `inputs` → SavedProject * - Se tem `v0` e `terrainCategory` → Snapshot do windStore * - Caso contrário → inválido */ export function detectFormat(parsed: unknown): 'saved-project' | 'snapshot' | 'unknown' { if (!isObject(parsed)) return 'unknown'; if (isString(parsed.module) && isObject(parsed.inputs)) return 'saved-project'; if ('v0' in parsed && ('terrainCategory' in parsed || 's2' in parsed)) return 'snapshot'; return 'unknown'; } /** * Valida um SavedProject. * * Retorna warnings (não-fatais) e erro (fatal) separadamente. */ export function validateSavedProject(raw: unknown): { ok: boolean; warnings: string[]; errors: string[]; } { const warnings: string[] = []; const errors: string[] = []; if (!isObject(raw)) { errors.push('JSON não é um objeto.'); return { ok: false, warnings, errors }; } if (!isString(raw.name)) { errors.push('Campo "name" ausente ou não é string.'); } if (!isString(raw.module) || !VALID_MODULES.includes(raw.module as ModuleId)) { errors.push(`Campo "module" ausente ou inválido (deve ser um de: ${VALID_MODULES.join(', ')}).`); } if (!isObject(raw.inputs)) { errors.push('Campo "inputs" ausente ou não é objeto.'); } if (!isNumber(raw.createdAt)) { warnings.push('Campo "createdAt" ausente — será gerado automaticamente.'); } if (!isNumber(raw.updatedAt)) { warnings.push('Campo "updatedAt" ausente — será gerado automaticamente.'); } return { ok: errors.length === 0, warnings, errors }; } /** * Valida um snapshot do windStore. */ export function validateSnapshot(raw: unknown): { ok: boolean; warnings: string[]; errors: string[]; } { const warnings: string[] = []; const errors: string[] = []; if (!isObject(raw)) { errors.push('JSON não é um objeto.'); return { ok: false, warnings, errors }; } if (!isNumber(raw.v0)) errors.push('Campo "v0" ausente ou não é número.'); if (!isNumber(raw.s1)) errors.push('Campo "s1" ausente ou não é número.'); if (!isNumber(raw.s3)) errors.push('Campo "s3" ausente ou não é número.'); if ( !isString(raw.terrainCategory) || !VALID_CATEGORIES.includes(raw.terrainCategory as TerrainCategory) ) { errors.push( `Campo "terrainCategory" inválido (deve ser um de: ${VALID_CATEGORIES.join(', ')}).`, ); } if (!isNumber(raw.s3Group)) warnings.push('Campo "s3Group" ausente — mantendo valor padrão.'); if (!isNumber(raw.largestDimension)) warnings.push('Campo "largestDimension" ausente — mantendo valor padrão.'); if (!isNumber(raw.heightZ)) warnings.push('Campo "heightZ" ausente — mantendo valor padrão.'); return { ok: errors.length === 0, warnings, errors }; } /** * Parseia uma string JSON com segurança. */ export function parseProjectJson(text: string): unknown { try { return JSON.parse(text); } catch (e) { throw new Error(`JSON inválido: ${e instanceof Error ? e.message : 'erro desconhecido'}`); } } /** * Aplica um SavedProject validado aos stores Zustand. * * Apenas o windStore e galpaoStore são atualizados neste MVP; * módulos futuros podem estender via dispatcher. */ export function applySavedProject(project: SavedProject): ImportResult { const appliedFields: string[] = []; const warnings: string[] = []; const wind = useWindStore.getState(); const inputs = project.inputs as Record; // Atualiza windStore se o snapshot estiver presente if ('wind' in inputs && isObject(inputs.wind)) { const w = inputs.wind; if (isNumber(w.v0)) { wind.setV0(w.v0); appliedFields.push('wind.v0'); } if (isNumber(w.s1)) { wind.setS1(w.s1); appliedFields.push('wind.s1'); } if (isNumber(w.terrainCategory) || isString(w.terrainCategory)) { const cat = String(w.terrainCategory); if (VALID_CATEGORIES.includes(cat as TerrainCategory)) { wind.setTerrainCategory(cat as TerrainCategory); appliedFields.push('wind.terrainCategory'); } else { warnings.push(`Categoria inválida: ${cat}`); } } if (isNumber(w.s3Group)) { wind.setS3Group(w.s3Group as 1 | 2 | 3 | 4 | 5); appliedFields.push('wind.s3Group'); } if (isNumber(w.largestDimension) && isNumber(w.heightZ)) { wind.setDimensions(w.largestDimension, w.heightZ); appliedFields.push('wind.dimensions'); } } // Atualiza galpaoStore se inputs do galpão if (project.module === 'galpao' && 'galpao' in inputs && isObject(inputs.galpao)) { const g = inputs.galpao; const galpao = useGalpaoStore.getState(); if (isNumber(g.width)) { galpao.setWidth(g.width); appliedFields.push('galpao.width'); } if (isNumber(g.length)) { galpao.setLength(g.length); appliedFields.push('galpao.length'); } if (isNumber(g.height)) { galpao.setHeight(g.height); appliedFields.push('galpao.height'); } if (isNumber(g.roofPitch)) { galpao.setRoofPitch(g.roofPitch); appliedFields.push('galpao.roofPitch'); } if (isNumber(g.windAngle) || (g.windAngle === 0 || g.windAngle === 90)) { wind.setWindAngle((g.windAngle as 0 | 90)); appliedFields.push('wind.windAngle'); } if (isString(g.permeabilityCase)) { wind.setPermeabilityCase(g.permeabilityCase as 'four-equally-permeable' | 'dominant-windward'); appliedFields.push('wind.permeabilityCase'); } if (isNumber(g.cpiRatio)) { wind.setCpiRatio(g.cpiRatio); appliedFields.push('wind.cpiRatio'); } } return { ok: true, module: project.module, projectName: project.name, appliedFields, warnings, }; } /** * Aplica um snapshot do windStore. */ export function applySnapshot(snapshot: Record): ImportResult { const appliedFields: string[] = []; const warnings: string[] = []; const wind = useWindStore.getState(); if (isNumber(snapshot.v0)) { wind.setV0(snapshot.v0); appliedFields.push('v0'); } if (isNumber(snapshot.s1)) { wind.setS1(snapshot.s1); appliedFields.push('s1'); } if (isNumber(snapshot.s3)) { wind.setS3(snapshot.s3); appliedFields.push('s3'); } if (isString(snapshot.terrainCategory)) { if (VALID_CATEGORIES.includes(snapshot.terrainCategory as TerrainCategory)) { wind.setTerrainCategory(snapshot.terrainCategory as TerrainCategory); appliedFields.push('terrainCategory'); } else { warnings.push(`Categoria inválida: ${snapshot.terrainCategory}`); } } if (isNumber(snapshot.s3Group)) { wind.setS3Group(snapshot.s3Group as 1 | 2 | 3 | 4 | 5); appliedFields.push('s3Group'); } if (isNumber(snapshot.largestDimension) && isNumber(snapshot.heightZ)) { wind.setDimensions(snapshot.largestDimension, snapshot.heightZ); appliedFields.push('dimensions'); } return { ok: true, appliedFields, warnings }; } /** * Atalho: parseia texto JSON, detecta formato, valida, aplica. */ export function importProjectFromText(text: string): ImportResult { let parsed: unknown; try { parsed = parseProjectJson(text); } catch (e) { return { ok: false, error: e instanceof Error ? e.message : 'Erro ao parsear JSON' }; } const format = detectFormat(parsed); if (format === 'saved-project') { const validation = validateSavedProject(parsed); if (!validation.ok) { return { ok: false, error: `Validação falhou: ${validation.errors.join('; ')}`, warnings: validation.warnings, }; } const project = parsed as SavedProject; const result = applySavedProject(project); return { ...result, warnings: [...(result.warnings ?? []), ...validation.warnings] }; } if (format === 'snapshot') { const validation = validateSnapshot(parsed); if (!validation.ok) { return { ok: false, error: `Validação falhou: ${validation.errors.join('; ')}`, warnings: validation.warnings, }; } const result = applySnapshot(parsed as Record); return { ...result, warnings: [...(result.warnings ?? []), ...validation.warnings] }; } return { ok: false, error: 'Formato não reconhecido. Esperado: SavedProject (com module/inputs) ou snapshot do windStore.', }; } /** * Cria um File picker e dispara callback com o conteúdo lido. */ export function readProjectFile(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => reject(reader.error ?? new Error('Falha ao ler arquivo')); reader.readAsText(file); }); } /** * Exporta um projeto para string JSON (roundtrip). * Útil para testes. */ export function exportProjectToJson(project: SavedProject): string { return JSON.stringify(project, null, 2); } /** * Serialização determinística para snapshot do windStore. */ export function snapshotWindStoreToJson(): string { const state = useWindStore.getState(); const snapshot = { v0: state.v0, s1: state.s1, s3: state.s3, s3Group: state.s3Group, terrainCategory: state.terrainCategory, largestDimension: state.largestDimension, heightZ: state.heightZ, s2: state.s2, vk: state.vk, q: state.q, structureClass: state.structureClass, }; return JSON.stringify(snapshot, null, 2); } /** * Detecção redundante para o módulo unimported (evita warning em build). */ export const _internals = { VALID_MODULES, VALID_CATEGORIES, isString, isNumber, isBoolean, isObject, };