From bdf064373aab1fd4127750919449dfec5a635e9f Mon Sep 17 00:00:00 2001 From: TopProjectsCreator Date: Sat, 18 Apr 2026 14:40:02 -0700 Subject: [PATCH] Fix Scratch import/export reliability --- src/components/scratch/ScratchPanel.tsx | 69 ++++++++++++++----------- src/services/scratchSb3.ts | 5 +- src/test/scratchSb3.test.ts | 15 ++++++ 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/components/scratch/ScratchPanel.tsx b/src/components/scratch/ScratchPanel.tsx index 4a42a5c5..6696ea61 100644 --- a/src/components/scratch/ScratchPanel.tsx +++ b/src/components/scratch/ScratchPanel.tsx @@ -3196,39 +3196,50 @@ export const ScratchPanel = ({ archive, onArchiveChange, onProjectJsonUpdate, is await loadVmFromArchive(normalizedArchive, inferredVersion); } catch (error) { setVmError(error instanceof Error ? error.message : 'Failed to import Scratch archive (.sb/.sb2/.sb3).'); + } finally { + if (importInputRef.current) { + importInputRef.current.value = ''; + } } }; const handleExport = async () => { - const exportFormat = scratchVersion === 'scratch3' ? 'sb3' : 'sb2'; - const semver = exportFormat === 'sb3' ? '3.0.0' : '2.0.0'; - const currentProject = safeParseProject(archive); - const normalizedProject: ScratchProject = { - ...currentProject, - projectVersion: exportFormat === 'sb3' ? 3 : 2, - targets: (currentProject.targets || []).map((target) => ({ - ...target, - blocks: normalizeBlocksForVersion(target.blocks, exportFormat === 'sb3' ? 'scratch3' : 'scratch2'), - })), - extensions: (currentProject.extensions || []).filter((ext) => exportFormat === 'sb3' || (ext !== 'pen' && ext !== 'music')), - meta: { - ...(currentProject.meta || {}), - semver, - }, - }; - const exportArchive = ensureArchive({ - ...ensureArchive(archive), - projectJson: formatJson(normalizedProject), - }); - const data = await exportScratchArchive(exportArchive, exportFormat); - const mime = exportFormat === 'sb3' ? 'application/x.scratch.sb3' : 'application/x.scratch.sb2'; - const blob = new Blob([data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer], { type: mime }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `project.${exportFormat}`; - a.click(); - URL.revokeObjectURL(url); + try { + const exportFormat = scratchVersion === 'scratch3' ? 'sb3' : 'sb2'; + const semver = exportFormat === 'sb3' ? '3.0.0' : '2.0.0'; + const currentProject = safeParseProject(archive); + const normalizedProject: ScratchProject = { + ...currentProject, + projectVersion: exportFormat === 'sb3' ? 3 : 2, + targets: (currentProject.targets || []).map((target) => ({ + ...target, + blocks: normalizeBlocksForVersion(target.blocks, exportFormat === 'sb3' ? 'scratch3' : 'scratch2'), + })), + extensions: (currentProject.extensions || []).filter((ext) => exportFormat === 'sb3' || (ext !== 'pen' && ext !== 'music')), + meta: { + ...(currentProject.meta || {}), + semver, + }, + }; + const exportArchive = ensureArchive({ + ...ensureArchive(archive), + projectJson: formatJson(normalizedProject), + }); + const data = await exportScratchArchive(exportArchive, exportFormat); + const mime = exportFormat === 'sb3' ? 'application/x.scratch.sb3' : 'application/x.scratch.sb2'; + const blob = new Blob([data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `project.${exportFormat}`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + setVmError(null); + } catch (error) { + setVmError(error instanceof Error ? error.message : 'Failed to export Scratch archive (.sb2/.sb3).'); + } }; const applyJsonDraft = async () => { diff --git a/src/services/scratchSb3.ts b/src/services/scratchSb3.ts index 9b4b947e..65f2bdd0 100644 --- a/src/services/scratchSb3.ts +++ b/src/services/scratchSb3.ts @@ -76,7 +76,10 @@ const convertLegacyProjectToSb3 = async (arrayBuffer: ArrayBuffer): Promise => { const zip = new JSZip(); - const fileNames = archive.fileNames.length > 0 ? archive.fileNames : Object.keys(archive.files); + const fileNames = Array.from(new Set([ + ...archive.fileNames, + ...Object.keys(archive.files), + ])); for (const name of fileNames) { if (name === 'project.json') continue; diff --git a/src/test/scratchSb3.test.ts b/src/test/scratchSb3.test.ts index 886d6da2..bd1d250d 100644 --- a/src/test/scratchSb3.test.ts +++ b/src/test/scratchSb3.test.ts @@ -23,4 +23,19 @@ describe('scratch sb3 import/export', () => { expect(outImported.archive.projectJson).toContain('Sprite1'); expect(outImported.archive.files['a.txt']).toBeTruthy(); }); + + it('exports assets present in files even when fileNames is stale', async () => { + const data = await exportSb3({ + projectJson: JSON.stringify({ targets: [], meta: { semver: '3.0.0' } }), + files: { + 'extra.txt': btoa('hello'), + }, + fileNames: ['project.json'], + }); + + const out = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; + const imported = await importSb3(out); + + expect(imported.archive.files['extra.txt']).toBeTruthy(); + }); });