extra() { return extra; }
/** Simple class name of the stage Run and Export use; empty = auto-detect. */
public String startStage = "";
public String flavour = LibraryFlavour.STANDARD.id();
diff --git a/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Importer.java b/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Importer.java
index b6fe9bc..21eabfb 100644
--- a/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Importer.java
+++ b/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Importer.java
@@ -34,8 +34,9 @@
* regions hold the backdrops, stage sounds and every sprite at its position,
* direction, size, costume, visibility, rotation style and layer.
*
- * Scripts are not converted (they are rebuilt in Java with the block
- * palette); the result lists what needs attention. Bitmap costumes are copied
+ *
Scripts are converted where a faithful Java equivalent is available.
+ * Migration tasks connect remaining work to original blocks and generated Java.
+ * Bitmap costumes are copied
* (Scratch stores them at {@code bitmapResolution} 2, so sizes are halved),
* vector costumes are rendered to PNG at 2x with Batik (the SVG is kept),
* and MP3 sounds are converted to WAV by the caller-supplied converter.
@@ -94,6 +95,7 @@ public static Result importProject(Path sb3, Path parentDir, String name, Path l
ProjectSettings settings = new ProjectSettings();
settings.startStage = result.stageClass();
settings.save(root);
+ Files.copy(sb3, root.resolve(".scratch4j/original.sb3"));
return result;
}
}
@@ -109,6 +111,8 @@ private static final class Importer {
"Text", "MyStage", "ScratchValues"));
private String globalFields = "";
private boolean needsValues;
+ private final List tasks = new ArrayList<>();
+ private boolean concurrentTargets;
Importer(ZipFile zip, Path root, Function mp3ToWav, boolean clones) {
this.zip = zip;
@@ -123,6 +127,12 @@ private record Costume(String name, String path, double centerX, double centerY,
private record SoundFile(String name, String path) {}
Result run(JsonNode project) throws IOException {
+ int flags = 0;
+ for (JsonNode target : project.path("targets")) for (var entry : target.path("blocks").properties()) {
+ if (entry.getValue().path("opcode").asText().equals("event_whenflagclicked")
+ && entry.getValue().path("topLevel").asBoolean(false)) flags++;
+ }
+ concurrentTargets = flags > 1;
JsonNode stage = null;
List sprites = new ArrayList<>();
for (JsonNode target : project.path("targets")) {
@@ -251,7 +261,7 @@ private void writeSpriteClass(String className, String scratchName, JsonNode spr
Map visible = new java.util.HashMap<>(globals);
String fields = declare(sprite, false, className, visible, new HashSet<>());
Sb3Scripts.Output scripts = new Sb3Scripts(sprite, false, "MyStage", visible, classOf)
- .withClones(clones).convert(fields);
+ .withClones(clones).withConcurrentTargets(concurrentTargets).convert(fields);
Files.writeString(root.resolve(className + ".java"),
classSource(className, "Sprite", "Imported from the Scratch sprite \""
+ scratchName.replace("*/", "") + "\".", scripts, setup.toString(), null),
@@ -259,8 +269,17 @@ private void writeSpriteClass(String className, String scratchName, JsonNode spr
note(scratchName, className, scripts);
}
- private void note(String owner, String className, Sb3Scripts.Output scripts) {
+ private void note(String owner, String className, Sb3Scripts.Output scripts) throws IOException {
needsValues |= scripts.needsValues();
+ String source = Files.readString(root.resolve(className + ".java"));
+ for (Sb3Scripts.Issue issue : scripts.issues()) {
+ int offset = source.indexOf(issue.marker());
+ int line = offset < 0 ? 1 : (int) source.substring(0, offset).chars().filter(c -> c == '\n').count() + 1;
+ tasks.add(new ScratchMigration.Task(className + "-" + issue.id(), issue.id(), issue.blockId(),
+ issue.opcode(), owner, className + ".java", line, issue.message(),
+ "https://scratch4j.openpatch.org/migration#" + issue.lesson(), issue.original()));
+ }
+ ScratchMigration.save(root, tasks);
if (scripts.scripts() > 0) {
notes.add(owner + ": " + scripts.scripts() + " script(s) converted into " + className
+ ".java" + (scripts.todos() == 0 ? "" : ", " + scripts.todos()
@@ -277,6 +296,11 @@ private String classSource(String className, String base, String comment,
StringBuilder sb = new StringBuilder();
sb.append("import java.util.ArrayList;\nimport java.util.List;\nimport org.openpatch.scratch.*;\n\n");
sb.append("// ").append(comment).append('\n');
+ String generated = scripts.constructor() + scripts.run() + scripts.methods();
+ for (Sb3Scripts.Issue issue : scripts.issues()) if (!generated.contains(issue.marker())) {
+ sb.append("// ").append(issue.marker()).append("\n// ")
+ .append(issue.message().replace("\n", " ")).append('\n');
+ }
if (scripts.scripts() > 0) {
sb.append("// Its scripts were converted").append(scripts.todos() == 0 ? "."
: "; look for \"TODO Scratch\" for blocks to finish by hand.").append('\n');
@@ -322,8 +346,8 @@ private void writeStageClass(String className, JsonNode stage, String fields, St
v.java().substring(v.java().indexOf('.') + 1), true, v.number(), v.list()));
}
Sb3Scripts.Output scripts = stage == null
- ? new Sb3Scripts.Output(globalFields, "", "", "", 0, 0, false)
- : new Sb3Scripts(stage, true, className, own, classOf).withClones(clones)
+ ? new Sb3Scripts.Output(globalFields, "", "", "", 0, 0, false, List.of())
+ : new Sb3Scripts(stage, true, className, own, classOf).withClones(clones).withConcurrentTargets(concurrentTargets)
.convert(globalFields);
Files.writeString(root.resolve(className + ".java"), classSource(className, "Stage",
"Imported from Scratch: the stage with its backdrops and sprites.", scripts, setup,
diff --git a/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Scripts.java b/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Scripts.java
index e1c9ad4..4523bf1 100644
--- a/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Scripts.java
+++ b/core/src/main/java/org/openpatch/scratch4j/core/project/Sb3Scripts.java
@@ -25,7 +25,23 @@ final class Sb3Scripts {
/** What one target contributes to its class. */
record Output(String fields, String constructor, String run, String methods, int scripts,
- int todos, boolean needsValues) {}
+ int todos, boolean needsValues, List issues) {}
+
+ record Issue(String id, String blockId, String opcode, String message, String lesson, String original) {
+ String marker() { return "scratch4j:migration " + id; }
+ }
+
+ private final List issues = new ArrayList<>();
+
+ private Issue issue(String blockId, String message, String lesson) {
+ JsonNode block = blocks.path(blockId);
+ String id = java.util.UUID.nameUUIDFromBytes((blockId + "|" + message)
+ .getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString();
+ for (Issue existing : issues) if (existing.id().equals(id)) return existing;
+ Issue issue = new Issue(id, blockId, op(block), message, lesson, block.toString());
+ issues.add(issue);
+ return issue;
+ }
private enum Type { NUM, STR, BOOL, OBJ }
@@ -49,6 +65,12 @@ record Variable(String java, boolean global, boolean number, boolean list) {}
private boolean clones;
/** This target makes or is clones: run() then tells the original and clones apart. */
private boolean usesClones;
+ private boolean concurrentTargets;
+
+ Sb3Scripts withConcurrentTargets(boolean concurrent) {
+ this.concurrentTargets = concurrent;
+ return this;
+ }
/**
* {@code variables} maps Scratch variable/list ids to Java names (globals
@@ -196,10 +218,31 @@ Output convert(String ownFields) {
methods.append(" }\n");
}
methods.append(helpers);
+ List hats = new ArrayList<>();
+ for (var entry : blocks.properties()) {
+ JsonNode block = entry.getValue();
+ if (!block.path("topLevel").asBoolean(false) || block.path("shadow").asBoolean(false)
+ || op(block).equals("procedures_definition")) continue;
+ hats.add(entry.getKey());
+ if (!SUPPORTED_HATS.contains(op(block)) || (stage && op(block).equals("event_whenstageclicked"))
+ || (!clones && op(block).equals("control_start_as_clone"))) {
+ issue(entry.getKey(), "This Scratch event needs a Java implementation. Its original script is preserved.",
+ "unsupported-blocks");
+ }
+ }
+ if (hats.size() > 1 || concurrentTargets) for (String id : hats) {
+ issue(id, "Scratch scripts can overlap. Java event handlers and helper calls run in sequence; review shared state and waiting.",
+ "shared-state");
+ }
return new Output(ownFields, constructor, run,
- methods + (other.length() > 0 ? "\n" + other : ""), scripts, todos, needsValues);
+ methods + (other.length() > 0 ? "\n" + other : ""), scripts, todos, needsValues, List.copyOf(issues));
}
+ private static final java.util.Set SUPPORTED_HATS = java.util.Set.of(
+ "event_whenflagclicked", "event_whenkeypressed", "event_whenthisspriteclicked",
+ "event_whenstageclicked", "event_whenbroadcastreceived", "event_whenbackdropswitchesto",
+ "control_start_as_clone");
+
/** Statements before a trailing forever run once; the forever body runs every frame. */
/** Statements before a trailing forever run once; the forever body runs every frame. */
private void flagScript(String first, List flags, List forevers) {
@@ -431,7 +474,13 @@ private String statement(String id, String indent, List chain, boolean o
}
if (code == null) {
todos++;
- return indent + "// TODO Scratch: " + describe(b) + "\n";
+ Issue task = issue(id, "Implement this block in Java: " + describe(b), "unsupported-blocks");
+ return indent + "// " + task.marker() + "\n" + indent + "// TODO Scratch: " + describe(b) + "\n";
+ }
+ if (code.contains("this block waited;")) {
+ Issue task = issue(id, "This Scratch block waited before the next block. Continue the sequence using run() and a timer.",
+ "timing");
+ code = "// " + task.marker() + "\n" + indent + code;
}
return indent + code.replace("\n" + indent + "}", "\n" + indent + "}") + "\n";
}
@@ -789,7 +838,14 @@ private Expr reporter(JsonNode b) {
break;
}
todos++;
- return new Expr("0 /* TODO Scratch: " + describe(b).replace("*/", "") + " */", Type.NUM);
+ String marker = "";
+ for (var entry : blocks.properties()) if (entry.getValue().equals(b)) {
+ Issue task = issue(entry.getKey(), "This reporter was replaced with 0. Implement the original expression before using its result.",
+ "unsupported-blocks");
+ marker = task.marker() + "; ";
+ break;
+ }
+ return new Expr("0 /* " + marker + "TODO Scratch: " + describe(b).replace("*/", "") + " */", Type.NUM);
}
private Expr arithmetic(JsonNode b, String operator) {
diff --git a/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchMigration.java b/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchMigration.java
new file mode 100644
index 0000000..d80c924
--- /dev/null
+++ b/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchMigration.java
@@ -0,0 +1,51 @@
+package org.openpatch.scratch4j.core.project;
+
+import java.nio.file.*;
+import java.io.IOException;
+import java.util.List;
+import java.util.ArrayList;
+import tools.jackson.databind.json.JsonMapper;
+
+/** Reviewable tasks retain the original block and a marker that survives source edits. */
+public final class ScratchMigration {
+ private ScratchMigration() {}
+ public record Task(String id, String markerId, String blockId, String opcode, String target,
+ String javaFile, int line, String message, String lessonUrl, String originalBlock) {}
+ public record Manifest(int schemaVersion, List tasks) {}
+
+ public static void save(Path root, List tasks) throws IOException {
+ Path file = root.resolve(".scratch4j/migration.json");
+ Files.createDirectories(file.getParent());
+ Path temporary = Files.createTempFile(file.getParent(), "migration-", ".tmp");
+ try {
+ Files.writeString(temporary, JsonMapper.builder().build().writerWithDefaultPrettyPrinter()
+ .writeValueAsString(new Manifest(1, List.copyOf(tasks))));
+ try { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); }
+ catch (AtomicMoveNotSupportedException e) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ public static List load(Path root) throws IOException {
+ Path file = root.resolve(".scratch4j/migration.json");
+ if (!Files.isRegularFile(file)) return List.of();
+ Manifest manifest;
+ try { manifest = JsonMapper.builder().build().readValue(file.toFile(), Manifest.class); }
+ catch (RuntimeException e) { throw new IOException("Invalid Scratch migration tasks", e); }
+ if (manifest.schemaVersion() != 1 || manifest.tasks() == null) throw new IOException("Unsupported migration schema");
+ List tasks = new ArrayList<>();
+ for (Task task : manifest.tasks()) {
+ Path source = root.resolve(task.javaFile()).normalize();
+ if (!source.startsWith(root.normalize()) || !task.javaFile().endsWith(".java"))
+ throw new IOException("Invalid migration source: " + task.javaFile());
+ int line = task.line();
+ if (Files.isRegularFile(source)) {
+ String text = Files.readString(source);
+ int offset = text.indexOf("scratch4j:migration " + task.markerId());
+ if (offset >= 0) line = (int) text.substring(0, offset).chars().filter(c -> c == '\n').count() + 1;
+ }
+ tasks.add(new Task(task.id(), task.markerId(), task.blockId(), task.opcode(), task.target(),
+ task.javaFile(), line, task.message(), task.lessonUrl(), task.originalBlock()));
+ }
+ return List.copyOf(tasks);
+ }
+}
diff --git a/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchProject.java b/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchProject.java
index 6016ce0..7207f52 100644
--- a/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchProject.java
+++ b/core/src/main/java/org/openpatch/scratch4j/core/project/ScratchProject.java
@@ -171,7 +171,7 @@ public String startStage() throws IOException {
private boolean hasMain(String className) throws IOException {
for (Path source : javaSources()) {
if (source.getFileName().toString().equals(className + ".java")) {
- return Pattern.compile("\\bstatic\\s+void\\s+main\\s*\\(")
+ return Pattern.compile("\\bvoid\\s+main\\s*\\(")
.matcher(Files.readString(source, StandardCharsets.UTF_8)).find();
}
}
diff --git a/core/src/main/java/org/openpatch/scratch4j/core/region/DesignerRegions.java b/core/src/main/java/org/openpatch/scratch4j/core/region/DesignerRegions.java
index 663ccd6..b8a35d7 100644
--- a/core/src/main/java/org/openpatch/scratch4j/core/region/DesignerRegions.java
+++ b/core/src/main/java/org/openpatch/scratch4j/core/region/DesignerRegions.java
@@ -33,7 +33,7 @@ public final class DesignerRegions {
private static final String STAGE_NOTE = " (managed by the stage designer)";
private static final Pattern CLASS = Pattern.compile(
"(?m)^([ \\t]*)public\\s+class\\s+(\\w+)\\s+extends\\s+(Stage|Sprite|AnimatedSprite|"
- + "UISprite|Window)\\b[^{]*\\{[ \\t]*\\n");
+ + "UISprite|Window)\\b[^{]*\\{[ \\t]*\\r?\\n");
private DesignerRegions() {}
@@ -67,6 +67,16 @@ public static String ensureStage(String source) {
* {@code Window}), whatever its direct superclass is.
*/
public static String ensure(String source, String kind) {
+ // Work with one line separator so offsets and inserted markers agree on Windows.
+ // Preserve the original source when no regions are needed, and its CRLF style
+ // when adding regions to a Windows file.
+ String normalized = source.replace("\r\n", "\n");
+ String updated = ensureNormalized(normalized, kind);
+ if (updated.equals(normalized)) return source;
+ return source.contains("\r\n") ? updated.replace("\n", "\r\n") : updated;
+ }
+
+ private static String ensureNormalized(String source, String kind) {
Matcher type = ANY_CLASS.matcher(source);
if (!type.find()) {
return source;
diff --git a/core/src/main/java/org/openpatch/scratch4j/core/region/SpriteLook.java b/core/src/main/java/org/openpatch/scratch4j/core/region/SpriteLook.java
index 3798131..32582df 100644
--- a/core/src/main/java/org/openpatch/scratch4j/core/region/SpriteLook.java
+++ b/core/src/main/java/org/openpatch/scratch4j/core/region/SpriteLook.java
@@ -5,6 +5,7 @@
import com.sun.source.tree.BlockTree;
import com.sun.source.tree.ClassTree;
import com.sun.source.tree.CompilationUnitTree;
+import com.sun.source.tree.ConditionalExpressionTree;
import com.sun.source.tree.ExpressionStatementTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.IdentifierTree;
@@ -235,6 +236,9 @@ private static boolean ints(List