// We don't generate new IDs using numbers at this time because their enumeration // order can affect script execution order as they always come first. // https://tc39.es/ecma262/#sec-ordinaryownpropertykeys const SOUP = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#%()*+,-./:;=?@[]^_`{|}~'; const generateId = i => { let str = ''; while (i >= 0) { str = SOUP[i % SOUP.length] + str; i = Math.floor(i / SOUP.length) - 1; } return str; }; class Pool { constructor () { this.generatedIds = new Map(); this.references = new Map(); this.skippedIds = new Set(); // IDs in Object.keys(vm.runtime.monitorBlocks._blocks) already have meaning, so make sure to skip those // We don't bother listing many here because most would take more than ten million items to be used this.skippedIds.add('of'); } skip (id) { this.skippedIds.add(id); } addReference (id) { const currentCount = this.references.get(id) || 0; this.references.set(id, currentCount + 1); } generateNewIds () { const entries = Array.from(this.references.entries()); // The most used original IDs should get the shortest new IDs. entries.sort((a, b) => b[1] - a[1]); let i = 0; for (const entry of entries) { const oldId = entry[0]; let newId = generateId(i); while (this.skippedIds.has(newId)) { i++; newId = generateId(i); } this.generatedIds.set(oldId, newId); i++; } } getNewId (originalId) { if (this.generatedIds.has(originalId)) { return this.generatedIds.get(originalId); } return originalId; } } const compress = projectData => { // projectData is modified in-place // The optimization here is not optimal. This is intentional. // We only compress block and comment IDs because we want to maintain 100% (not 99.99%; 100%) compatibility and be // truly lossless. Optimizing things like variable IDs will cause things such as the editor's backpack feature // to misbehave. // We use the same variable pool for all objects to avoid any possible issues if IDs are ever treated as unique // within a given project. const pool = new Pool(); for (const target of projectData.targets) { // While we don't compress these IDs, we need to make sure that our compressed IDs // do not intersect, which could happen if the project was compressed with a // different tool. for (const variableId of Object.keys(target.variables)) { pool.skip(variableId); } for (const listId of Object.keys(target.lists)) { pool.skip(listId); } for (const broadcastId of Object.keys(target.broadcasts)) { pool.skip(broadcastId); } for (const blockId of Object.keys(target.blocks)) { const block = target.blocks[blockId]; pool.addReference(blockId); if (Array.isArray(block)) { // Compressed native continue; } if (block.parent) { pool.addReference(block.parent); } if (block.next) { pool.addReference(block.next); } if (block.comment) { pool.addReference(block.comment); } for (const input of Object.values(block.inputs)) { for (let i = 1; i < input.length; i++) { const inputValue = input[i]; if (typeof inputValue === 'string') { pool.addReference(inputValue); } } } } for (const commentId of Object.keys(target.comments)) { const comment = target.comments[commentId]; pool.addReference(commentId); if (comment.blockId) { pool.addReference(comment.blockId); } } } pool.generateNewIds(); for (const target of projectData.targets) { const newBlocks = {}; const newComments = {}; for (const blockId of Object.keys(target.blocks)) { const block = target.blocks[blockId]; newBlocks[pool.getNewId(blockId)] = block; if (Array.isArray(block)) { // Compressed native continue; } if (block.parent) { block.parent = pool.getNewId(block.parent); } if (block.next) { block.next = pool.getNewId(block.next); } if (block.comment) { block.comment = pool.getNewId(block.comment); } for (const input of Object.values(block.inputs)) { for (let i = 1; i < input.length; i++) { const inputValue = input[i]; if (typeof inputValue === 'string') { input[i] = pool.getNewId(inputValue); } } } } for (const commentId of Object.keys(target.comments)) { const comment = target.comments[commentId]; newComments[pool.getNewId(commentId)] = comment; if (comment.blockId) { comment.blockId = pool.getNewId(comment.blockId); } } target.blocks = newBlocks; target.comments = newComments; } }; module.exports = compress;