Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 40 additions & 29 deletions src/components/scratch/ScratchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 4 additions & 1 deletion src/services/scratchSb3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ const convertLegacyProjectToSb3 = async (arrayBuffer: ArrayBuffer): Promise<Arra

export const exportSb3 = async (archive: ScratchArchive): Promise<Uint8Array> => {
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;
Expand Down
15 changes: 15 additions & 0 deletions src/test/scratchSb3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading