Skip to content

Commit bcae33b

Browse files
authored
Merge pull request #295 from FAReTek1/sbeditor
sbeditor (Rival to project_json_capabilites) @ scratchattach.editor
2 parents 9432bb1 + 9fe3d68 commit bcae33b

34 files changed

+7019
-129
lines changed

scratchattach/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@
2323
from .site.classroom import Classroom, get_classroom
2424
from .site.user import User, get_user
2525
from .site._base import BaseSiteComponent
26+
27+
from . import editor

scratchattach/editor/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
scratchattach.editor (sbeditor v2) - a library for all things sb3
3+
"""
4+
5+
from .asset import Asset, Costume, Sound
6+
from .project import Project
7+
from .extension import Extensions, Extension
8+
from .mutation import Mutation, Argument, parse_proc_code
9+
from .meta import Meta, set_meta_platform
10+
from .sprite import Sprite
11+
from .block import Block
12+
from .prim import Prim, PrimTypes
13+
from .backpack_json import load_script as load_script_from_backpack
14+
from .twconfig import TWConfig, is_valid_twconfig
15+
from .inputs import Input, ShadowStatuses
16+
from .field import Field
17+
from .vlb import Variable, List, Broadcast
18+
from .comment import Comment
19+
from .monitor import Monitor
20+
21+
from .build_defaulting import add_chain, add_comment, add_block

scratchattach/editor/asset.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from hashlib import md5
5+
import requests
6+
7+
from . import base, commons, sprite, build_defaulting
8+
9+
10+
@dataclass(init=True, repr=True)
11+
class AssetFile:
12+
filename: str
13+
_data: bytes = field(repr=False, default=None)
14+
_md5: str = field(repr=False, default=None)
15+
16+
@property
17+
def data(self):
18+
if self._data is None:
19+
# Download and cache
20+
rq = requests.get(f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}/get/")
21+
if rq.status_code != 200:
22+
raise ValueError(f"Can't download asset {self.filename}\nIs not uploaded to scratch! Response: {rq.text}")
23+
24+
self._data = rq.content
25+
26+
return self._data
27+
28+
@property
29+
def md5(self):
30+
if self._md5 is None:
31+
self._md5 = md5(self.data).hexdigest()
32+
33+
return self._md5
34+
35+
36+
class Asset(base.SpriteSubComponent):
37+
def __init__(self,
38+
name: str = "costume1",
39+
file_name: str = "b7853f557e4426412e64bb3da6531a99.svg",
40+
_sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT):
41+
"""
42+
Represents a generic asset. Can be a sound or an image.
43+
https://en.scratch-wiki.info/wiki/Scratch_File_Format#Assets
44+
"""
45+
try:
46+
asset_id, data_format = file_name.split('.')
47+
except ValueError:
48+
raise ValueError(f"Invalid file name: {file_name}, # of '.' in {file_name} ({file_name.count('.')}) != 2; "
49+
f"(too many/few values to unpack)")
50+
self.name = name
51+
52+
self.id = asset_id
53+
self.data_format = data_format
54+
55+
super().__init__(_sprite)
56+
57+
def __repr__(self):
58+
return f"Asset<{self.name!r}>"
59+
60+
@property
61+
def folder(self):
62+
return commons.get_folder_name(self.name)
63+
64+
@property
65+
def name_nfldr(self):
66+
return commons.get_name_nofldr(self.name)
67+
68+
@property
69+
def file_name(self):
70+
return f"{self.id}.{self.data_format}"
71+
72+
@property
73+
def md5ext(self):
74+
return self.file_name
75+
76+
@property
77+
def parent(self):
78+
if self.project is None:
79+
return self.sprite
80+
else:
81+
return self.project
82+
83+
@property
84+
def asset_file(self) -> AssetFile:
85+
for asset_file in self.parent.asset_data:
86+
if asset_file.filename == self.file_name:
87+
return asset_file
88+
89+
# No pre-existing asset file object; create one and add it to the project
90+
asset_file = AssetFile(self.file_name)
91+
self.project.asset_data.append(asset_file)
92+
return asset_file
93+
94+
@staticmethod
95+
def from_json(data: dict):
96+
_name = data.get("name")
97+
_file_name = data.get("md5ext")
98+
if _file_name is None:
99+
if "dataFormat" in data and "assetId" in data:
100+
_id = data["assetId"]
101+
_data_format = data["dataFormat"]
102+
_file_name = f"{_id}.{_data_format}"
103+
104+
return Asset(_name, _file_name)
105+
106+
def to_json(self) -> dict:
107+
return {
108+
"name": self.name,
109+
110+
"assetId": self.id,
111+
"md5ext": self.file_name,
112+
"dataFormat": self.data_format,
113+
}
114+
115+
"""
116+
@staticmethod
117+
def from_file(fp: str, name: str = None):
118+
image_types = ("png", "jpg", "jpeg", "svg")
119+
sound_types = ("wav", "mp3")
120+
121+
# Should save data as well so it can be uploaded to scratch if required (add to project asset data)
122+
...
123+
"""
124+
125+
126+
class Costume(Asset):
127+
def __init__(self,
128+
name: str = "Cat",
129+
file_name: str = "b7853f557e4426412e64bb3da6531a99.svg",
130+
131+
bitmap_resolution=None,
132+
rotation_center_x: int | float = 48,
133+
rotation_center_y: int | float = 50,
134+
_sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT):
135+
"""
136+
A costume. An asset with additional properties
137+
https://en.scratch-wiki.info/wiki/Scratch_File_Format#Costumes
138+
"""
139+
super().__init__(name, file_name, _sprite)
140+
141+
self.bitmap_resolution = bitmap_resolution
142+
self.rotation_center_x = rotation_center_x
143+
self.rotation_center_y = rotation_center_y
144+
145+
@staticmethod
146+
def from_json(data):
147+
_asset_load = Asset.from_json(data)
148+
149+
bitmap_resolution = data.get("bitmapResolution")
150+
151+
rotation_center_x = data["rotationCenterX"]
152+
rotation_center_y = data["rotationCenterY"]
153+
return Costume(_asset_load.name, _asset_load.file_name,
154+
155+
bitmap_resolution, rotation_center_x, rotation_center_y)
156+
157+
def to_json(self) -> dict:
158+
_json = super().to_json()
159+
_json.update({
160+
"bitmapResolution": self.bitmap_resolution,
161+
"rotationCenterX": self.rotation_center_x,
162+
"rotationCenterY": self.rotation_center_y
163+
})
164+
return _json
165+
166+
167+
class Sound(Asset):
168+
def __init__(self,
169+
name: str = "pop",
170+
file_name: str = "83a9787d4cb6f3b7632b4ddfebf74367.wav",
171+
172+
rate: int = None,
173+
sample_count: int = None,
174+
_sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT):
175+
"""
176+
A sound. An asset with additional properties
177+
https://en.scratch-wiki.info/wiki/Scratch_File_Format#Sounds
178+
"""
179+
super().__init__(name, file_name, _sprite)
180+
181+
self.rate = rate
182+
self.sample_count = sample_count
183+
184+
@staticmethod
185+
def from_json(data):
186+
_asset_load = Asset.from_json(data)
187+
188+
rate = data.get("rate")
189+
sample_count = data.get("sampleCount")
190+
return Sound(_asset_load.name, _asset_load.file_name, rate, sample_count)
191+
192+
def to_json(self) -> dict:
193+
_json = super().to_json()
194+
commons.noneless_update(_json, {
195+
"rate": self.rate,
196+
"sampleCount": self.sample_count
197+
})
198+
return _json

scratchattach/editor/backpack_json.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
Module to deal with the backpack's weird JSON format, by overriding with new load methods
3+
"""
4+
from __future__ import annotations
5+
6+
from . import block, prim, field, inputs, mutation, sprite
7+
8+
9+
def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]:
10+
"""
11+
Function for reading the fields in a backpack **primitive**
12+
"""
13+
for key, value in _fields.items():
14+
key: str
15+
value: dict[str, str]
16+
prim_value, prim_name, prim_id = (None,) * 3
17+
if key == "NUM":
18+
prim_value = value.get("value")
19+
else:
20+
prim_name = value.get("value")
21+
prim_id = value.get("id")
22+
23+
# There really should only be 1 item, and this function can only return for that item
24+
return prim_value, prim_name, prim_id
25+
return (None,) * 3
26+
27+
28+
class BpField(field.Field):
29+
"""
30+
A normal field but with a different load method
31+
"""
32+
33+
@staticmethod
34+
def from_json(data: dict[str, str]) -> field.Field:
35+
# We can very simply convert it to the regular format
36+
data = [data.get("value"), data.get("id")]
37+
return field.Field.from_json(data)
38+
39+
40+
class BpInput(inputs.Input):
41+
"""
42+
A normal input but with a different load method
43+
"""
44+
45+
@staticmethod
46+
def from_json(data: dict[str, str]) -> inputs.Input:
47+
# The actual data is stored in a separate prim block
48+
_id = data.get("shadow")
49+
_obscurer_id = data.get("block")
50+
51+
if _obscurer_id == _id:
52+
# If both the shadow and obscurer are the same, then there is no actual obscurer
53+
_obscurer_id = None
54+
# We cannot work out the shadow status yet since that is located in the primitive
55+
return inputs.Input(None, _id=_id, _obscurer_id=_obscurer_id)
56+
57+
58+
class BpBlock(block.Block):
59+
"""
60+
A normal block but with a different load method
61+
"""
62+
63+
@staticmethod
64+
def from_json(data: dict) -> prim.Prim | block.Block:
65+
"""
66+
Load a block in the **backpack** JSON format
67+
:param data: A dictionary (not list)
68+
:return: A new block/prim object
69+
"""
70+
_opcode = data["opcode"]
71+
72+
_x, _y = data.get("x"), data.get("y")
73+
if prim.is_prim_opcode(_opcode):
74+
# This is actually a prim
75+
prim_value, prim_name, prim_id = parse_prim_fields(data["fields"])
76+
return prim.Prim(prim.PrimTypes.find(_opcode, "opcode"),
77+
prim_value, prim_name, prim_id)
78+
79+
_next_id = data.get("next")
80+
_parent_id = data.get("parent")
81+
82+
_shadow = data.get("shadow", False)
83+
_top_level = data.get("topLevel", _parent_id is None)
84+
85+
_inputs = {}
86+
for _input_code, _input_data in data.get("inputs", {}).items():
87+
_inputs[_input_code] = BpInput.from_json(_input_data)
88+
89+
_fields = {}
90+
for _field_code, _field_data in data.get("fields", {}).items():
91+
_fields[_field_code] = BpField.from_json(_field_data)
92+
93+
if "mutation" in data:
94+
_mutation = mutation.Mutation.from_json(data["mutation"])
95+
else:
96+
_mutation = None
97+
98+
return block.Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id,
99+
_parent_id=_parent_id)
100+
101+
102+
def load_script(_script_data: list[dict]) -> sprite.Sprite:
103+
"""
104+
Loads a script into a sprite from the backpack JSON format
105+
:param _script_data: Backpack script JSON data
106+
:return: a blockchain object containing the script
107+
"""
108+
# Using a sprite since it simplifies things, e.g. local global loading
109+
_blockchain = sprite.Sprite()
110+
111+
for _block_data in _script_data:
112+
_block = BpBlock.from_json(_block_data)
113+
_block.sprite = _blockchain
114+
_blockchain.blocks[_block_data["id"]] = _block
115+
116+
_blockchain.link_subcomponents()
117+
return _blockchain

0 commit comments

Comments
 (0)