Files
BrainWind/app/src/lib/import-project.ts
T

383 lines
11 KiB
TypeScript

/**
* 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<string, unknown> {
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<string, unknown>;
// 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<string, unknown>): 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<string, unknown>);
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<string> {
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,
};