import type { ProjectManifest } from './types'; const TEXT_EXTENSIONS = /\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i; const decoder = new TextDecoder('utf-8', { fatal: false }); const encoder = new TextEncoder(); export function isEditableSource(path: string): boolean { return TEXT_EXTENSIONS.test(path); } export function editableSourcePaths(manifest: ProjectManifest): string[] { return manifest.files .filter((file) => isEditableSource(file.path)) .map((file) => file.path) .sort((a, b) => a.localeCompare(b)); } export function readCachedText(manifest: ProjectManifest, path: string): string { const file = manifest.files.find((candidate) => candidate.path === path); if (!file) throw new Error(`缓存中找不到文件:${path}`); if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`); return decoder.decode(file.data); } /** 返回只更新浏览器会话内存的新工程清单,不接触用户本地文件系统。 */ export function mergeCachedFiles( manifest: ProjectManifest, additional: ProjectManifest['files'], ): ProjectManifest { if (!additional.length) return manifest; const byPath = new Map(manifest.files.map((file) => [file.path, file])); for (const file of additional) byPath.set(file.path, file); const files = Array.from(byPath.values()); return { ...manifest, files, totalBytes: files.reduce((total, item) => total + item.size, 0) }; } export function upsertCachedMjcf( manifest: ProjectManifest, path: string, text: string, ): ProjectManifest { const data = encoder.encode(text), index = manifest.files.findIndex((candidate) => candidate.path === path); const files = manifest.files.slice(); const file = { path, data, size: data.byteLength, source: 'file' as const, mimeType: 'application/xml', }; if (index < 0) files.push(file); else files[index] = { ...files[index], ...file }; const entries = manifest.entries.some((entry) => entry.path === path) ? manifest.entries : [...manifest.entries, { path, format: 'mjcf' as const, label: `${path} (MJCF 缓存)` }]; return { ...manifest, files, entries, totalBytes: files.reduce((total, item) => total + item.size, 0), }; } export function updateCachedText( manifest: ProjectManifest, path: string, text: string, ): ProjectManifest { const index = manifest.files.findIndex((candidate) => candidate.path === path); if (index < 0) throw new Error(`缓存中找不到文件:${path}`); if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`); const data = encoder.encode(text), files = manifest.files.slice(); files[index] = { ...files[index], data, size: data.byteLength, mimeType: files[index].mimeType || 'text/plain', }; return { ...manifest, files, totalBytes: files.reduce((total, file) => total + file.size, 0) }; } export function downloadBytes( data: Uint8Array, fileName: string, mimeType = 'application/xml', ): void { const blob = new Blob([data as BlobPart], { type: `${mimeType};charset=utf-8` }); const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); } export function exportedFileName(projectName: string, extension: 'urdf' | 'xml'): string { const stem = projectName.replace(/\.(?:zip|xml|urdf)$/i, '').replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'model'; return `${stem}.${extension}`; }