Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2dadc30
add starter yml
rammodhvadia Jun 5, 2026
dfe4b87
allow processing sb3 files
rammodhvadia Jun 5, 2026
2c31782
initial parser attempt
rammodhvadia Jun 5, 2026
2277ac0
update parser to work with GH webhook
rammodhvadia Jun 8, 2026
e539c07
implement scratch importing in project importer
rammodhvadia Jun 8, 2026
f058478
update upload job to accept and process sb3 files
rammodhvadia Jun 8, 2026
29d0a7a
update filesystem_project for sb3 support
rammodhvadia Jun 8, 2026
fdc9728
tiny clean up on sb3 parser
rammodhvadia Jun 8, 2026
eb9cba1
Merge branch 'main' into sb3parser
rammodhvadia Jun 9, 2026
bddaab6
add asset importing for sb3 files
rammodhvadia Jun 9, 2026
42c2ef2
update sb3 parser to pull files from zip
rammodhvadia Jun 9, 2026
e63e015
call asset importer in project importer
rammodhvadia Jun 9, 2026
5a4c68b
fix import bug
rammodhvadia Jun 9, 2026
676f9fe
refactor asset importer for consistency with original importer
rammodhvadia Jun 11, 2026
888151c
clean up
rammodhvadia Jun 11, 2026
d9eed53
separate scratch component in upload job
rammodhvadia Jun 11, 2026
a6ac0ac
rubocop
rammodhvadia Jun 11, 2026
5c0c65a
add sb3 parser tests
rammodhvadia Jun 11, 2026
5ec7c72
clear up confusing naming
rammodhvadia Jun 12, 2026
6ae3fd7
update tests for sb3 import functionality
rammodhvadia Jun 12, 2026
490d99b
Merge branch 'main' into sb3parser
jamdelion Jun 16, 2026
806e5e1
separate sb3 asset importer to it's own file
rammodhvadia Jun 17, 2026
e2fe5e3
default to importing first component item for scratch projects
rammodhvadia Jun 18, 2026
d13114c
scratch project config yaml doesn't need components section
rammodhvadia Jun 18, 2026
9b53375
handle empty parsed_content for sb3 file
rammodhvadia Jun 18, 2026
70dfe53
fix tests + rubocop
rammodhvadia Jun 18, 2026
03d188d
Merge branch 'main' into sb3parser
rammodhvadia Jun 18, 2026
9141993
fix tests
rammodhvadia Jun 18, 2026
ea51c47
add sb3 file for sample project
rammodhvadia Jun 18, 2026
4bfe526
fix return conditions on project_importer
rammodhvadia Jun 18, 2026
13b859f
add require zip for sb3 archive helper
rammodhvadia Jun 18, 2026
8bb3e85
skip sb3 component import for non scratch projects
rammodhvadia Jun 18, 2026
5eebc0c
implement review suggestions
rammodhvadia Jun 19, 2026
efb7216
use casecmp to check sb3 extension
rammodhvadia Jun 19, 2026
ecd7281
move asset importer into project_importer
rammodhvadia Jun 22, 2026
42ca093
remove uploaded_user_id validation on asset
rammodhvadia Jun 22, 2026
4effe1e
rubocop
rammodhvadia Jun 22, 2026
a860704
update tests
rammodhvadia Jun 22, 2026
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
18 changes: 16 additions & 2 deletions app/jobs/upload_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ def categorize_files(files, project_dir, locale, repository, owner)
}

files.each do |file|
if file.extension == '.sb3'
categories[:components] << scratch_file_component(file, project_dir, locale, repository, owner)
next
Comment thread
cursor[bot] marked this conversation as resolved.
end
Comment thread
patch0 marked this conversation as resolved.

mime_type = file_mime_type(file)

case mime_type
Expand Down Expand Up @@ -158,11 +163,20 @@ def component(file)
{ name:, extension:, content:, default: }
end

def scratch_file_component(file, project_dir, locale, repository, owner)
name = file.name.chomp(file.extension)
extension = file.extension[1..]
{ name:, extension:, io: URI.parse(file_url(file, project_dir, locale, repository, owner)).open }
end

def media(file, project_dir, locale, repository, owner)
filename = file.name
{ filename:, io: URI.parse(file_url(file, project_dir, locale, repository, owner)).open }
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
end

def file_url(file, project_dir, locale, repository, owner)
directory = project_dir.name
url = "https://github.com/#{owner}/#{repository}/raw/#{ENV.fetch('GITHUB_WEBHOOK_REF')}/#{locale}/code/#{directory}/#{filename}"
{ filename:, io: URI.parse(url).open }
"https://github.com/#{owner}/#{repository}/raw/#{ENV.fetch('GITHUB_WEBHOOK_REF')}/#{locale}/code/#{directory}/#{file.name}"
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
end

def repository(payload)
Expand Down
14 changes: 12 additions & 2 deletions app/models/filesystem_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
require 'yaml'

class FilesystemProject
CODE_FORMATS = ['.py', '.csv', '.txt', '.html', '.css'].freeze
CODE_FORMATS = ['.py', '.csv', '.txt', '.html', '.css', '.sb3'].freeze
PROJECTS_ROOT = Rails.root.join('lib/tasks/project_components')
PROJECT_CONFIG = 'project_config.yml'

def self.import_all!
PROJECTS_ROOT.each_child do |dir|
proj_config = YAML.safe_load_file(dir.join(PROJECT_CONFIG).to_s)

files = dir.children.reject { |file| file.basename.to_s == 'project_config.yml' }
files = dir.children.reject { |file| file.basename.to_s == PROJECT_CONFIG }
files = configured_scratch_files(files, proj_config) if proj_config['TYPE'] == Project::Types::CODE_EDITOR_SCRATCH
categorized_files = categorize_files(files, dir)

project_importer = ProjectImporter.new(name: proj_config['NAME'], identifier: proj_config['IDENTIFIER'],
Expand Down Expand Up @@ -53,9 +54,18 @@ def self.categorize_files(files, dir)
categories
end

def self.configured_scratch_files(files, proj_config)
configured_locations = Array(proj_config['COMPONENTS']).pluck('location')
return files if configured_locations.empty?

files.reject { |file| File.extname(file) == '.sb3' && configured_locations.exclude?(file.basename.to_s) }
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
end

def self.component(file, dir)
name = File.basename(file, '.*')
extension = File.extname(file).delete('.')
return { name:, extension:, file_path: dir.join(File.basename(file)).to_s } if extension == 'sb3'

code = File.read(dir.join(File.basename(file)).to_s)
default = (File.basename(file) == 'main.py')
{ name:, extension:, content: code, default: }
Expand Down
28 changes: 28 additions & 0 deletions lib/project_importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def import!
setup_project
delete_components
create_components
create_scratch_component
create_scratch_assets
delete_removed_media
attach_media_if_needed

Expand All @@ -39,16 +41,42 @@ def setup_project
end

def delete_components
return unless project.project_type != 'code_editor_scratch'
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated

Comment thread
rammodhvadia marked this conversation as resolved.
project.components.each(&:destroy)
end

def create_components
return unless project.project_type != 'code_editor_scratch'
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated

components.each do |component|
project_component = Component.new(**component)
project.components << project_component
end
end

def create_scratch_component
return unless project.project_type == 'code_editor_scratch'

components.each do |component|
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
next unless component[:extension] == 'sb3'

parsed_content = Sb3Parser.new(component: component).parse.fetch(:scratch_component).fetch(:content)
project.scratch_component = ScratchComponent.new(content: parsed_content) if parsed_content
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
end
end

def create_scratch_assets
return unless project.project_type == 'code_editor_scratch'

components.each do |component|
next unless component[:extension] == 'sb3'

parsed_assets = Sb3Parser.new(component: component).parse.fetch(:assets)
ScratchAssetImporter.import_all_from_sb3(parsed_assets)
end
end
Comment thread
rammodhvadia marked this conversation as resolved.

def delete_removed_media
return if removed_media_names.empty?

Expand Down
76 changes: 76 additions & 0 deletions lib/sb3_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

require 'json'
require 'marcel'
require 'stringio'
require 'zip'

class Sb3Parser
class MissingProjectJsonError < StandardError; end
class MissingAssetError < StandardError; end

attr_reader :component, :file_path, :io

def initialize(component: nil, file_path: nil)
@component = component
@file_path = component&.fetch(:file_path, nil) || file_path
@io = component&.fetch(:io, nil)
end

def parse
open_zip do |zip_file|
project_json = project_json_entry(zip_file)
content = JSON.parse(project_json.get_input_stream.read)

output = {
scratch_component: { content: },
assets: assets(zip_file, extract_asset_names(content))
}
output
end
end

private

def open_zip(&)
return Zip::File.open(file_path, &) if file_path

io.rewind if io.respond_to?(:rewind)
result = nil
Zip::File.open_buffer(io.read) { |zip_file| result = yield zip_file }
result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the rewind needed? Also you don't need to assign the result, but you can call return inside the block.

Suggested change
io.rewind if io.respond_to?(:rewind)
result = nil
Zip::File.open_buffer(io.read) { |zip_file| result = yield zip_file }
result
Zip::File.open_buffer(io.read) do
return yield it
end

end

def project_json_entry(zip_file)
zip_file.find_entry('project.json') || raise(MissingProjectJsonError, 'project.json not found in SB3 archive')
end

def extract_asset_names(value)
case value
when Hash
names = []
names << value['md5ext'] if value['md5ext'].is_a?(String)
value.each_value { |item| names.concat(extract_asset_names(item)) }
names.uniq
when Array
value.flat_map { |item| extract_asset_names(item) }.uniq
else
[]
end
end

def assets(zip_file, asset_names)
asset_names.map do |asset_name|
entry = zip_file.find_entry(asset_name) || raise(MissingAssetError, "asset #{asset_name} not found in SB3 archive")
asset(entry)
end
end

def asset(entry)
io = StringIO.new(entry.get_input_stream.read)
content_type = Marcel::MimeType.for(io, name: entry.name)
io.rewind

{ filename: entry.name, io:, content_type: }
end
end
23 changes: 23 additions & 0 deletions lib/scratch_asset_importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ def import_all(asset_names, asset_base_url)
end
end

def import_all_from_sb3(assets)
assets.each do |asset|
new(nil, nil).import_from_sb3(asset)
end
end

private

def show_progress?
Expand Down Expand Up @@ -45,6 +51,12 @@ def asset
end
end

def import_from_sb3(asset)
create_sb3_asset(asset.fetch(:filename), asset.fetch(:io).read)
end

private

def create_scratch_asset
return if ScratchAsset.global_assets.exists?(filename: asset_name)

Expand All @@ -55,6 +67,17 @@ def create_scratch_asset
.attach(io:, filename: asset_name)
end

def create_sb3_asset(asset_name, content)
return if ScratchAsset.global_assets.exists?(filename: asset_name)

sleep(ASSET_FETCHING_DELAY)
ScratchAsset.create!(filename: asset_name, project_id: nil, uploaded_user_id: nil)
.file
.attach(io: StringIO.new(content), filename: asset_name)
rescue StandardError => e
Rails.logger.error("Failed to import SB3 asset #{asset_name}: #{e.message}")
end

Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
def save_to_editor_asset_bucket
return unless save_to_editor_asset_bucket?

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
NAME: "scratch integration test"
IDENTIFIER: "editor-scratch-testing-starter"
TYPE: "code_editor_scratch"
COMPONENTS:
- name: "main"
extension: "sb3"
location: "main.sb3"
index: 0
default: true
Comment thread
rammodhvadia marked this conversation as resolved.
Outdated
110 changes: 110 additions & 0 deletions spec/jobs/upload_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,116 @@
end
end

context 'when a scratch project is uploaded' do
let(:scratch_payload) do
{
repository: { name: 'my-amazing-repo', owner: { name: 'me' } },
commits: [{ added: ['ja-JP/code/scratch-integration-test-starter/main.sb3'], modified: [], removed: [] }]
}
end
let(:scratch_project_json) do
{
targets: [
{
costumes: [{ md5ext: 'test_image_1.png' }],
sounds: [{ md5ext: 'test_audio_1.mp3' }]
}
]
}
end
let(:scratch_sb3_body) do
sb3_archive_string(
'project.json' => scratch_project_json.to_json,
'test_image_1.png' => sb3_fixture_content('test_image_1.png'),
'test_audio_1.mp3' => sb3_fixture_content('test_audio_1.mp3')
)
end
let(:raw_response) do
{
data: {
repository: {
object: {
__typename: 'Tree',
entries: [
{
name: 'scratch-integration-test-starter',
object: {
__typename: 'Tree',
entries: [
{
name: 'main.sb3',
extension: '.sb3',
object: {
__typename: 'Blob',
text: nil,
isBinary: true
}
},
{
name: 'project_config.yml',
extension: '.yml',
object: {
__typename: 'Blob',
text: "name: \"Scratch Integration Test\"\nidentifier: \"scratch-integration-test-starter\"\ntype: \"code_editor_scratch\"\n",
isBinary: false
}
}
]
}
}
]
}
}
}
}.deep_stringify_keys
end

before do
allow(GithubApi::Client).to receive(:query).and_return(graphql_response)
allow(ProjectImporter).to receive(:new).and_call_original

stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/scratch-integration-test-starter/main.sb3')
.to_return(status: 200, body: scratch_sb3_body, headers: {})
end

it 'imports the Scratch project with the sb3 component as io' do
described_class.perform_now(scratch_payload)

expect(ProjectImporter).to have_received(:new).with(
hash_including(
name: 'Scratch Integration Test',
identifier: 'scratch-integration-test-starter',
type: Project::Types::CODE_EDITOR_SCRATCH,
locale: 'ja-JP',
images: [],
videos: [],
audio: [],
components: [
hash_including(
name: 'main',
extension: 'sb3',
io: an_object_responding_to(:read)
)
]
)
)
end

it 'requests the sb3 file from the correct URL' do
described_class.perform_now(scratch_payload)

expect(WebMock).to have_requested(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/scratch-integration-test-starter/main.sb3').once
end

it 'saves the Scratch project to the database' do
expect { described_class.perform_now(scratch_payload) }.to change(Project, :count).by(1)

project = Project.find_by(identifier: 'scratch-integration-test-starter', locale: 'ja-JP')
expect(project.project_type).to eq(Project::Types::CODE_EDITOR_SCRATCH)
expect(project.scratch_component.content).to eq(JSON.parse(scratch_project_json.to_json))
end
end

context 'when locale is unsupported' do
let(:raw_response) { { data: { repository: nil } } }
let(:bad_payload) do
Expand Down
Loading
Loading