mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-23 06:23:37 -05:00
Store asset objects on costumes and sounds
Stop using storage for in-memory storage, and keep these on the vm objects. Towards https://github.com/LLK/scratch-vm/issues/1577
This commit is contained in:
parent
891f696570
commit
b47912dce4
9 changed files with 90 additions and 90 deletions
|
@ -11,12 +11,8 @@ const loadVector_ = function (costume, costumeAsset, runtime, rotationCenter, op
|
||||||
svgString = runtime.v2SvgAdapter.toString();
|
svgString = runtime.v2SvgAdapter.toString();
|
||||||
// Put back into storage
|
// Put back into storage
|
||||||
const storage = runtime.storage;
|
const storage = runtime.storage;
|
||||||
costumeAsset.encodeTextData(svgString, storage.DataFormat.SVG);
|
costume.asset.encodeTextData(svgString, storage.DataFormat.SVG, true);
|
||||||
costume.assetId = storage.builtinHelper.cache(
|
costume.assetId = costume.asset.assetId;
|
||||||
storage.AssetType.ImageVector,
|
|
||||||
storage.DataFormat.SVG,
|
|
||||||
costumeAsset.data
|
|
||||||
);
|
|
||||||
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
||||||
}
|
}
|
||||||
// createSVGSkin does the right thing if rotationCenter isn't provided, so it's okay if it's
|
// createSVGSkin does the right thing if rotationCenter isn't provided, so it's okay if it's
|
||||||
|
@ -63,11 +59,14 @@ const loadBitmap_ = function (costume, costumeAsset, runtime, rotationCenter) {
|
||||||
} else if (dataURI) {
|
} else if (dataURI) {
|
||||||
// Put back into storage
|
// Put back into storage
|
||||||
const storage = runtime.storage;
|
const storage = runtime.storage;
|
||||||
costume.assetId = storage.builtinHelper.cache(
|
costume.asset = storage.createAsset(
|
||||||
storage.AssetType.ImageBitmap,
|
storage.AssetType.ImageBitmap,
|
||||||
storage.DataFormat.PNG,
|
storage.DataFormat.PNG,
|
||||||
runtime.v2BitmapAdapter.convertDataURIToBinary(dataURI)
|
runtime.v2BitmapAdapter.convertDataURIToBinary(dataURI),
|
||||||
|
null,
|
||||||
|
true // generate md5
|
||||||
);
|
);
|
||||||
|
costume.assetId = costume.asset.assetId;
|
||||||
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
||||||
}
|
}
|
||||||
// Regardless of if conversion succeeds, convert it to bitmap resolution 2,
|
// Regardless of if conversion succeeds, convert it to bitmap resolution 2,
|
||||||
|
@ -163,9 +162,12 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
|
||||||
const md5 = idParts[0];
|
const md5 = idParts[0];
|
||||||
const ext = idParts[1].toLowerCase();
|
const ext = idParts[1].toLowerCase();
|
||||||
const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap;
|
const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap;
|
||||||
|
|
||||||
return runtime.storage.load(assetType, md5, ext).then(costumeAsset => {
|
|
||||||
costume.dataFormat = ext;
|
costume.dataFormat = ext;
|
||||||
|
return (
|
||||||
|
(costume.asset && Promise.resolve(costume.asset)) ||
|
||||||
|
runtime.storage.load(assetType, md5, ext)
|
||||||
|
).then(costumeAsset => {
|
||||||
|
costume.asset = costumeAsset;
|
||||||
return loadCostumeFromAsset(costume, costumeAsset, runtime, optVersion);
|
return loadCostumeFromAsset(costume, costumeAsset, runtime, optVersion);
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
|
|
|
@ -55,9 +55,12 @@ const loadSound = function (sound, runtime, sprite) {
|
||||||
const idParts = StringUtil.splitFirst(sound.md5, '.');
|
const idParts = StringUtil.splitFirst(sound.md5, '.');
|
||||||
const md5 = idParts[0];
|
const md5 = idParts[0];
|
||||||
const ext = idParts[1].toLowerCase();
|
const ext = idParts[1].toLowerCase();
|
||||||
return runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
|
|
||||||
.then(soundAsset => {
|
|
||||||
sound.dataFormat = ext;
|
sound.dataFormat = ext;
|
||||||
|
return (
|
||||||
|
(sound.asset && Promise.resolve(sound.asset)) ||
|
||||||
|
runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
|
||||||
|
).then(soundAsset => {
|
||||||
|
sound.asset = soundAsset;
|
||||||
return loadSoundFromAsset(sound, soundAsset, runtime, sprite);
|
return loadSoundFromAsset(sound, soundAsset, runtime, sprite);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,37 +22,29 @@ const deserializeSound = function (sound, runtime, zip, assetFileName) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetId = sound.assetId;
|
|
||||||
|
|
||||||
// TODO Is there a faster way to check that this asset
|
|
||||||
// has already been initialized?
|
|
||||||
if (storage.get(assetId)) {
|
|
||||||
// This sound has already been cached.
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
if (!zip) { // Zip will not be provided if loading project json from server
|
if (!zip) { // Zip will not be provided if loading project json from server
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const soundFile = zip.file(fileName);
|
const soundFile = zip.file(fileName);
|
||||||
if (!soundFile) {
|
if (!soundFile) {
|
||||||
log.error(`Could not find sound file associated with the ${sound.name} sound.`);
|
log.error(`Could not find sound file associated with the ${sound.name} sound.`);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
const dataFormat = sound.dataFormat.toLowerCase() === 'mp3' ?
|
|
||||||
storage.DataFormat.MP3 : storage.DataFormat.WAV;
|
|
||||||
if (!JSZip.support.uint8array) {
|
if (!JSZip.support.uint8array) {
|
||||||
log.error('JSZip uint8array is not supported in this browser.');
|
log.error('JSZip uint8array is not supported in this browser.');
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return soundFile.async('uint8array').then(data => {
|
const dataFormat = sound.dataFormat.toLowerCase() === 'mp3' ?
|
||||||
storage.builtinHelper.cache(
|
storage.DataFormat.MP3 : storage.DataFormat.WAV;
|
||||||
|
return soundFile.async('uint8array').then(data => storage.createAsset(
|
||||||
storage.AssetType.Sound,
|
storage.AssetType.Sound,
|
||||||
dataFormat,
|
dataFormat,
|
||||||
data,
|
data,
|
||||||
assetId
|
sound.assetId
|
||||||
);
|
));
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -79,14 +71,6 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// TODO Is there a faster way to check that this asset
|
|
||||||
// has already been initialized?
|
|
||||||
if (storage.get(assetId)) {
|
|
||||||
// This costume has already been cached.
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zip) { // Zip will not be provided if loading project json from server
|
if (!zip) { // Zip will not be provided if loading project json from server
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
@ -110,15 +94,13 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return costumeFile.async('uint8array').then(data => {
|
return costumeFile.async('uint8array').then(data => storage.createAsset(
|
||||||
storage.builtinHelper.cache(
|
|
||||||
assetType,
|
assetType,
|
||||||
// TODO eventually we want to map non-png's to their actual file types?
|
// TODO eventually we want to map non-png's to their actual file types?
|
||||||
costumeFormat,
|
costumeFormat,
|
||||||
data,
|
data,
|
||||||
assetId
|
assetId
|
||||||
);
|
));
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
@ -452,7 +452,11 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip)
|
||||||
// the file name of the costume should be the baseLayerID followed by the file ext
|
// the file name of the costume should be the baseLayerID followed by the file ext
|
||||||
const assetFileName = `${costumeSource.baseLayerID}.${ext}`;
|
const assetFileName = `${costumeSource.baseLayerID}.${ext}`;
|
||||||
costumePromises.push(deserializeCostume(costume, runtime, zip, assetFileName)
|
costumePromises.push(deserializeCostume(costume, runtime, zip, assetFileName)
|
||||||
.then(() => loadCostume(costume.md5, costume, runtime, 2 /* optVersion */)));
|
.then(asset => {
|
||||||
|
costume.asset = asset;
|
||||||
|
return loadCostume(costume.md5, costume, runtime, 2 /* optVersion */);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sounds from JSON
|
// Sounds from JSON
|
||||||
|
@ -486,7 +490,10 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip)
|
||||||
// followed by the file ext
|
// followed by the file ext
|
||||||
const assetFileName = `${soundSource.soundID}.${ext}`;
|
const assetFileName = `${soundSource.soundID}.${ext}`;
|
||||||
soundPromises.push(deserializeSound(sound, runtime, zip, assetFileName)
|
soundPromises.push(deserializeSound(sound, runtime, zip, assetFileName)
|
||||||
.then(() => loadSound(sound, runtime, sprite)));
|
.then(asset => {
|
||||||
|
sound.asset = asset;
|
||||||
|
return loadSound(sound, runtime, sprite);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -844,7 +844,10 @@ const parseScratchObject = function (object, runtime, extensions, zip) {
|
||||||
// any translation that needs to happen will happen in the process
|
// any translation that needs to happen will happen in the process
|
||||||
// of building up the costume object into an sb3 format
|
// of building up the costume object into an sb3 format
|
||||||
return deserializeCostume(costume, runtime, zip)
|
return deserializeCostume(costume, runtime, zip)
|
||||||
.then(() => loadCostume(costumeMd5Ext, costume, runtime));
|
.then(asset => {
|
||||||
|
costume.asset = asset;
|
||||||
|
return loadCostume(costumeMd5Ext, costume, runtime);
|
||||||
|
});
|
||||||
// Only attempt to load the costume after the deserialization
|
// Only attempt to load the costume after the deserialization
|
||||||
// process has been completed
|
// process has been completed
|
||||||
});
|
});
|
||||||
|
@ -869,7 +872,10 @@ const parseScratchObject = function (object, runtime, extensions, zip) {
|
||||||
// any translation that needs to happen will happen in the process
|
// any translation that needs to happen will happen in the process
|
||||||
// of building up the costume object into an sb3 format
|
// of building up the costume object into an sb3 format
|
||||||
return deserializeSound(sound, runtime, zip)
|
return deserializeSound(sound, runtime, zip)
|
||||||
.then(() => loadSound(sound, runtime, sprite));
|
.then(asset => {
|
||||||
|
sound.asset = asset;
|
||||||
|
return loadSound(sound, runtime, sprite);
|
||||||
|
});
|
||||||
// Only attempt to load the sound after the deserialization
|
// Only attempt to load the sound after the deserialization
|
||||||
// process has been completed.
|
// process has been completed.
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,12 +16,10 @@ const serializeAssets = function (runtime, assetType, optTargetId) {
|
||||||
const currAssets = currTarget.sprite[assetType];
|
const currAssets = currTarget.sprite[assetType];
|
||||||
for (let j = 0; j < currAssets.length; j++) {
|
for (let j = 0; j < currAssets.length; j++) {
|
||||||
const currAsset = currAssets[j];
|
const currAsset = currAssets[j];
|
||||||
const assetId = currAsset.assetId;
|
const asset = currAsset.asset;
|
||||||
const storage = runtime.storage;
|
|
||||||
const storedAsset = storage.get(assetId);
|
|
||||||
assetDescs.push({
|
assetDescs.push({
|
||||||
fileName: `${assetId}.${storedAsset.dataFormat}`,
|
fileName: `${asset.assetId}.${asset.dataFormat}`,
|
||||||
fileContent: storedAsset.data});
|
fileContent: asset.data});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return assetDescs;
|
return assetDescs;
|
||||||
|
|
|
@ -146,14 +146,14 @@ class Sprite {
|
||||||
|
|
||||||
newSprite.costumes = this.costumes_.map(costume => {
|
newSprite.costumes = this.costumes_.map(costume => {
|
||||||
const newCostume = Object.assign({}, costume);
|
const newCostume = Object.assign({}, costume);
|
||||||
const costumeAsset = this.runtime.storage.get(costume.assetId);
|
const costumeAsset = costume.asset;
|
||||||
assetPromises.push(loadCostumeFromAsset(newCostume, costumeAsset, this.runtime));
|
assetPromises.push(loadCostumeFromAsset(newCostume, costumeAsset, this.runtime));
|
||||||
return newCostume;
|
return newCostume;
|
||||||
});
|
});
|
||||||
|
|
||||||
newSprite.sounds = this.sounds.map(sound => {
|
newSprite.sounds = this.sounds.map(sound => {
|
||||||
const newSound = Object.assign({}, sound);
|
const newSound = Object.assign({}, sound);
|
||||||
const soundAsset = this.runtime.storage.get(sound.assetId);
|
const soundAsset = sound.asset;
|
||||||
assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime, newSprite));
|
assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime, newSprite));
|
||||||
return newSound;
|
return newSound;
|
||||||
});
|
});
|
||||||
|
|
|
@ -690,11 +690,14 @@ class VirtualMachine extends EventEmitter {
|
||||||
// is updated as below.
|
// is updated as below.
|
||||||
sound.format = '';
|
sound.format = '';
|
||||||
const storage = this.runtime.storage;
|
const storage = this.runtime.storage;
|
||||||
sound.assetId = storage.builtinHelper.cache(
|
sound.asset = storage.createAsset(
|
||||||
storage.AssetType.Sound,
|
storage.AssetType.Sound,
|
||||||
storage.DataFormat.WAV,
|
storage.DataFormat.WAV,
|
||||||
soundEncoding
|
soundEncoding,
|
||||||
|
null,
|
||||||
|
true // generate md5
|
||||||
);
|
);
|
||||||
|
sound.assetId = sound.asset.assetId;
|
||||||
sound.dataFormat = storage.DataFormat.WAV;
|
sound.dataFormat = storage.DataFormat.WAV;
|
||||||
sound.md5 = `${sound.assetId}.${sound.dataFormat}`;
|
sound.md5 = `${sound.assetId}.${sound.dataFormat}`;
|
||||||
}
|
}
|
||||||
|
@ -731,16 +734,16 @@ class VirtualMachine extends EventEmitter {
|
||||||
* a dataURI if it's a PNG or JPG, or null if it couldn't be found or decoded.
|
* a dataURI if it's a PNG or JPG, or null if it couldn't be found or decoded.
|
||||||
*/
|
*/
|
||||||
getCostume (costumeIndex) {
|
getCostume (costumeIndex) {
|
||||||
const id = this.editingTarget.getCostumes()[costumeIndex].assetId;
|
const asset = this.editingTarget.getCostumes()[costumeIndex].asset;
|
||||||
if (!id || !this.runtime || !this.runtime.storage) return null;
|
if (!asset || !this.runtime || !this.runtime.storage) return null;
|
||||||
const format = this.runtime.storage.get(id).dataFormat;
|
const format = asset.dataFormat;
|
||||||
if (format === this.runtime.storage.DataFormat.SVG) {
|
if (format === this.runtime.storage.DataFormat.SVG) {
|
||||||
return this.runtime.storage.get(id).decodeText();
|
return asset.decodeText();
|
||||||
} else if (format === this.runtime.storage.DataFormat.PNG ||
|
} else if (format === this.runtime.storage.DataFormat.PNG ||
|
||||||
format === this.runtime.storage.DataFormat.JPG) {
|
format === this.runtime.storage.DataFormat.JPG) {
|
||||||
return this.runtime.storage.get(id).encodeDataURI();
|
return asset.encodeDataURI();
|
||||||
}
|
}
|
||||||
log.error(`Unhandled format: ${this.runtime.storage.get(id).dataFormat}`);
|
log.error(`Unhandled format: ${asset.dataFormat}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -781,14 +784,17 @@ class VirtualMachine extends EventEmitter {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.addEventListener('loadend', () => {
|
reader.addEventListener('loadend', () => {
|
||||||
const storage = this.runtime.storage;
|
const storage = this.runtime.storage;
|
||||||
costume.assetId = storage.builtinHelper.cache(
|
|
||||||
storage.AssetType.ImageBitmap,
|
|
||||||
storage.DataFormat.PNG,
|
|
||||||
Buffer.from(reader.result)
|
|
||||||
);
|
|
||||||
costume.dataFormat = storage.DataFormat.PNG;
|
costume.dataFormat = storage.DataFormat.PNG;
|
||||||
costume.bitmapResolution = bitmapResolution;
|
costume.bitmapResolution = bitmapResolution;
|
||||||
costume.size = [bitmap.width, bitmap.height];
|
costume.size = [bitmap.width, bitmap.height];
|
||||||
|
costume.asset = storage.createAsset(
|
||||||
|
storage.AssetType.ImageBitmap,
|
||||||
|
costume.dataFormat,
|
||||||
|
Buffer.from(reader.result),
|
||||||
|
null, // id
|
||||||
|
true // generate md5
|
||||||
|
);
|
||||||
|
costume.assetId = costume.asset.assetId;
|
||||||
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
||||||
this.emitTargetsUpdate();
|
this.emitTargetsUpdate();
|
||||||
});
|
});
|
||||||
|
@ -812,16 +818,19 @@ class VirtualMachine extends EventEmitter {
|
||||||
costume.size = this.runtime.renderer.getSkinSize(costume.skinId);
|
costume.size = this.runtime.renderer.getSkinSize(costume.skinId);
|
||||||
}
|
}
|
||||||
const storage = this.runtime.storage;
|
const storage = this.runtime.storage;
|
||||||
costume.assetId = storage.builtinHelper.cache(
|
|
||||||
storage.AssetType.ImageVector,
|
|
||||||
storage.DataFormat.SVG,
|
|
||||||
(new TextEncoder()).encode(svg)
|
|
||||||
);
|
|
||||||
// If we're in here, we've edited an svg in the vector editor,
|
// If we're in here, we've edited an svg in the vector editor,
|
||||||
// so the dataFormat should be 'svg'
|
// so the dataFormat should be 'svg'
|
||||||
costume.dataFormat = storage.DataFormat.SVG;
|
costume.dataFormat = storage.DataFormat.SVG;
|
||||||
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
|
||||||
costume.bitmapResolution = 1;
|
costume.bitmapResolution = 1;
|
||||||
|
costume.asset = storage.createAsset(
|
||||||
|
storage.AssetType.ImageVector,
|
||||||
|
costume.dataFormat,
|
||||||
|
(new TextEncoder()).encode(svg),
|
||||||
|
null,
|
||||||
|
true // generate md5
|
||||||
|
);
|
||||||
|
costume.assetId = costume.asset.assetId;
|
||||||
|
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
|
||||||
this.emitTargetsUpdate();
|
this.emitTargetsUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ const test = require('tap').test;
|
||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
const ScratchStorage = require('scratch-storage');
|
const ScratchStorage = require('scratch-storage');
|
||||||
const VirtualMachine = require('../../src/index');
|
const VirtualMachine = require('../../src/index');
|
||||||
const StringUtil = require('../../src/util/string-util');
|
|
||||||
|
|
||||||
const projectUri = path.resolve(__dirname, '../fixtures/offline-custom-assets.sb2');
|
const projectUri = path.resolve(__dirname, '../fixtures/offline-custom-assets.sb2');
|
||||||
const projectZip = AdmZip(projectUri);
|
const projectZip = AdmZip(projectUri);
|
||||||
|
@ -52,11 +51,8 @@ test('offline-custom-assets', t => {
|
||||||
t.equals(costumes.length, 1);
|
t.equals(costumes.length, 1);
|
||||||
const customCostume = costumes[0];
|
const customCostume = costumes[0];
|
||||||
t.equals(customCostume.name, 'A_Test_Costume');
|
t.equals(customCostume.name, 'A_Test_Costume');
|
||||||
const costumeMd5Ext = customCostume.md5;
|
|
||||||
const costumeIdParts = StringUtil.splitFirst(costumeMd5Ext, '.');
|
|
||||||
const costumeMd5 = costumeIdParts[0];
|
|
||||||
|
|
||||||
const storedCostume = vm.runtime.storage.get(costumeMd5);
|
const storedCostume = customCostume.asset;
|
||||||
t.type(storedCostume, 'object');
|
t.type(storedCostume, 'object');
|
||||||
t.deepEquals(storedCostume.data, costumeData);
|
t.deepEquals(storedCostume.data, costumeData);
|
||||||
|
|
||||||
|
@ -64,10 +60,7 @@ test('offline-custom-assets', t => {
|
||||||
t.equals(sounds.length, 1);
|
t.equals(sounds.length, 1);
|
||||||
const customSound = sounds[0];
|
const customSound = sounds[0];
|
||||||
t.equals(customSound.name, 'A_Test_Recording');
|
t.equals(customSound.name, 'A_Test_Recording');
|
||||||
const soundMd5Ext = customSound.md5;
|
const storedSound = customSound.asset;
|
||||||
const soundIdParts = StringUtil.splitFirst(soundMd5Ext, '.');
|
|
||||||
const soundMd5 = soundIdParts[0];
|
|
||||||
const storedSound = vm.runtime.storage.get(soundMd5);
|
|
||||||
t.type(storedSound, 'object');
|
t.type(storedSound, 'object');
|
||||||
t.deepEquals(storedSound.data, soundData);
|
t.deepEquals(storedSound.data, soundData);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue