383 lines
11 KiB
TypeScript
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,
|
|
}; |