Refresh the homepage and improve export and draft guidance
This commit is contained in:
@@ -1 +1 @@
|
||||
allprojects { group = 'dev.minegeo'; version = '0.1.0'; repositories { mavenCentral() } }
|
||||
allprojects { group = 'dev.minegeo'; version = '0.1.1'; repositories { mavenCentral() } }
|
||||
|
||||
@@ -17,6 +17,6 @@ public final class CaptureScreen extends Screen {
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,182,260,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,210,260,20).build());
|
||||
}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website (HTTPS outside localhost)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
@Override public boolean shouldPause(){return false;}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public static MineGeoClient INSTANCE;
|
||||
public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;});
|
||||
public final Gson gson=new GsonBuilder().setPrettyPrinting().create();
|
||||
public String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
|
||||
public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
|
||||
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
|
||||
private Path config;
|
||||
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
|
||||
@@ -31,5 +31,5 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
|
||||
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,144 @@
|
||||
package dev.minegeo.plugin;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
public final class MineGeoPlugin extends JavaPlugin {
|
||||
private World selectedWorld;
|
||||
private int minX=-256,minZ=-256,maxX=256,maxZ=256;
|
||||
private final AtomicBoolean cancelled=new AtomicBoolean();
|
||||
private volatile String status="Idle";
|
||||
private volatile String status="Idle — /minegeo export <map name> to begin";
|
||||
private volatile boolean busy;
|
||||
private final Gson gson=new Gson();
|
||||
@Override public void onEnable(){saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);getLogger().info("MineGeo ready. /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");}
|
||||
private final Gson gson=new GsonBuilder().setPrettyPrinting().create();
|
||||
private volatile Path latest;
|
||||
@Override public void onEnable(){
|
||||
saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);
|
||||
try {
|
||||
Files.createDirectories(getDataFolder().toPath().resolve("exports"));
|
||||
Path root=getDataFolder().toPath().toAbsolutePath().normalize(),pointer=root.resolve("latest-export.txt");
|
||||
if(Files.exists(pointer)) {Path candidate=root.resolve(Files.readString(pointer).trim()).normalize();if(candidate.startsWith(root)&&Files.isRegularFile(candidate.resolve("manifest.json")))latest=candidate;}
|
||||
Path legacy=root.resolve("export");if(latest==null&&Files.isRegularFile(legacy.resolve("manifest.json")))latest=legacy;
|
||||
}catch(IOException ex){getLogger().log(Level.WARNING,"Cannot prepare export folder",ex);}
|
||||
getLogger().info("MineGeo ready. /minegeo help — exports: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
|
||||
}
|
||||
@Override public void onDisable(){cancelled.set(true);}
|
||||
@Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){
|
||||
if(!sender.hasPermission("minegeo.admin")){sender.sendMessage("MineGeo: administrator permission required.");return true;}
|
||||
try {
|
||||
if(args.length==0){sender.sendMessage("MineGeo: area, export, status, cancel, link, upload");return true;}
|
||||
switch(args[0].toLowerCase(Locale.ROOT)){
|
||||
case "area" -> { if(busy)throw new IllegalArgumentException("Cancel the current export before changing the area.");if(args.length!=6)throw new IllegalArgumentException("area <world> <minX> <minZ> <maxX> <maxZ>");World w=Bukkit.getWorld(args[1]);if(w==null||w.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");int ax=Integer.parseInt(args[2]),az=Integer.parseInt(args[3]),bx=Integer.parseInt(args[4]),bz=Integer.parseInt(args[5]);int cap=Math.min(2048,getConfig().getInt("max-area-size",2048));if(bx<=ax||bz<=az||bx-ax>cap||bz-az>cap)throw new IllegalArgumentException("Invalid bounds; maximum "+cap+" blocks per side.");selectedWorld=w;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+w.getName()+" "+ax+","+az+" to "+bx+","+bz);}
|
||||
case "status" -> sender.sendMessage("MineGeo: "+status);
|
||||
case "cancel" -> {cancelled.set(true);sender.sendMessage("MineGeo cancellation requested.");}
|
||||
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo status | cancel | reload"});return true;}
|
||||
switch(args[0].toLowerCase(Locale.ROOT)) {
|
||||
case "area" -> {
|
||||
requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");
|
||||
World world=Bukkit.getWorld(args[1]);if(world==null||world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");
|
||||
int ax=Integer.parseInt(args[2]),az=Integer.parseInt(args[3]),bx=Integer.parseInt(args[4]),bz=Integer.parseInt(args[5]);int cap=Math.max(16,Math.min(2048,getConfig().getInt("max-area-size",2048)));
|
||||
if(bx<=ax||bz<=az||(long)bx-ax>cap||(long)bz-az>cap||Math.abs((long)ax)>30000000||Math.abs((long)az)>30000000||Math.abs((long)bx)>30000000||Math.abs((long)bz)>30000000)throw new IllegalArgumentException("Invalid bounds; maximum "+cap+" blocks per side, within the world border.");
|
||||
selectedWorld=world;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+world.getName()+" "+ax+","+az+" to "+bx+","+bz);
|
||||
}
|
||||
case "status" -> {sender.sendMessage("MineGeo: "+status);if(latest!=null)sender.sendMessage("Latest complete export: "+latest.toAbsolutePath());}
|
||||
case "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");}
|
||||
case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
|
||||
case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website <http(s)://address:port>");new UploadClient(args[1],"");getConfig().set("website",args[1]);saveConfig();sender.sendMessage("MineGeo website saved: "+args[1]);}
|
||||
case "link" -> {requireIdle();if(args.length!=2||args[1].isBlank())throw new IllegalArgumentException("Usage: /minegeo link <dashboard token>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved.");}
|
||||
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer");
|
||||
case "link" -> { if(args.length!=2)throw new IllegalArgumentException("link <account token from MineGeo dashboard>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved. Restrict access to config.yml.");}
|
||||
case "upload" -> {if(busy)throw new IllegalArgumentException("Wait for the current operation.");busy=true;Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{status="Uploading";new dev.minegeo.plugin.UploadClient(getConfig().getString("website"),getConfig().getString("token")).upload(getDataFolder().toPath().resolve("export"));status="Upload complete; review the private draft on MineGeo.";}catch(Exception ex){status="Upload failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}});sender.sendMessage("MineGeo upload started.");}
|
||||
default -> throw new IllegalArgumentException("Unknown command.");
|
||||
case "upload" -> upload(sender);
|
||||
default -> throw new IllegalArgumentException("Unknown command. Use /minegeo help.");
|
||||
}
|
||||
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}
|
||||
return true;
|
||||
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}return true;
|
||||
}
|
||||
private void requireIdle(){if(busy)throw new IllegalArgumentException("An operation is running. Use /minegeo status or /minegeo cancel.");}
|
||||
private void report(CommandSender sender,String message){status=message;getLogger().info(message);if(sender instanceof Player&&isEnabled())Bukkit.getScheduler().runTask(this,()->sender.sendMessage("MineGeo: "+message));}
|
||||
private void fail(CommandSender sender,String phase,Exception ex){report(sender,cancelled.get()?"Cancelled. Completed exports are kept.":phase+" failed: "+ex.getMessage());if(!cancelled.get())getLogger().log(Level.WARNING,phase+" failure",ex);}
|
||||
private void upload(CommandSender sender)throws Exception {
|
||||
requireIdle();if(latest==null||!Files.isRegularFile(latest.resolve("manifest.json")))throw new IOException("No completed export. Run /minegeo export My World, wait for 'Export complete', then /minegeo upload. Files: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
|
||||
String website=getConfig().getString("website"),token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link <token>.");
|
||||
UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview the private draft after processing.");}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
|
||||
sender.sendMessage("MineGeo upload started. /minegeo status shows progress; retry /minegeo upload after a connection failure.");
|
||||
}
|
||||
private record Captured(int cx,int cz,ChunkSnapshot snapshot){}
|
||||
private void export(CommandSender sender,String name)throws IOException{
|
||||
if(busy)throw new IllegalArgumentException("An export or upload is already running.");
|
||||
World world=selectedWorld!=null?selectedWorld:Bukkit.getWorlds().getFirst();
|
||||
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only.");
|
||||
busy=true;cancelled.set(false);List<int[]> coords=new ArrayList<>();
|
||||
for(int cz=Math.floorDiv(minZ,16);cz<=Math.floorDiv(maxZ-1,16);cz++)for(int cx=Math.floorDiv(minX,16);cx<=Math.floorDiv(maxX-1,16);cx++)if(world.isChunkGenerated(cx,cz))coords.add(new int[]{cx,cz});
|
||||
if(coords.isEmpty()){busy=false;throw new IllegalArgumentException("No generated chunks in the selected area.");}
|
||||
Path out=getDataFolder().toPath().resolve("export");Files.createDirectories(out);
|
||||
List<Captured> captured=new ArrayList<>();int lower=world.getMinHeight(),upper=world.getMaxHeight();
|
||||
sender.sendMessage("MineGeo snapshotting "+coords.size()+" generated chunks. No terrain will be generated.");
|
||||
new BukkitRunnable(){int index;
|
||||
private record Area(int minX,int minZ,int maxX,int maxZ){}
|
||||
private void export(CommandSender sender,String name)throws IOException {
|
||||
requireIdle();if(name.length()<3||name.length()>80)throw new IllegalArgumentException("Map name must contain 3–80 characters.");
|
||||
String serverName=getConfig().getString("server-name","My Minecraft server");if(serverName.length()<2||serverName.length()>80)throw new IllegalArgumentException("server-name in config.yml must contain 2–80 characters.");
|
||||
World world=selectedWorld!=null?selectedWorld:sender instanceof Player p?p.getWorld():Bukkit.getWorlds().stream().filter(w->w.getEnvironment()==World.Environment.NORMAL).findFirst().orElseThrow();
|
||||
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only. Select it with /minegeo area.");
|
||||
Location center=sender instanceof Player p?p.getLocation():world.getSpawnLocation();
|
||||
int half=Math.max(8,Math.min(256,getConfig().getInt("max-area-size",2048)/2));
|
||||
Area area=selectedWorld==null?new Area(center.getBlockX()-half,center.getBlockZ()-half,center.getBlockX()+half,center.getBlockZ()+half):new Area(minX,minZ,maxX,maxZ);
|
||||
Path out=getDataFolder().toPath().resolve("exports").resolve("export-"+System.currentTimeMillis());Files.createDirectories(out);
|
||||
int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
|
||||
ArrayBlockingQueue<Captured> queue=new ArrayBlockingQueue<>(4);AtomicBoolean done=new AtomicBoolean();AtomicReference<Exception> failure=new AtomicReference<>();
|
||||
busy=true;cancelled.set(false);status="Export started: "+out.toAbsolutePath();sender.sendMessage("MineGeo: "+status);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(this,()->{
|
||||
try{encode(queue,done,failure,out,name,serverName,area,lower,upper,desired);Path root=getDataFolder().toPath(),pointer=root.resolve("latest-export.txt.tmp");Files.writeString(pointer,root.relativize(out).toString());Files.move(pointer,root.resolve("latest-export.txt"),StandardCopyOption.REPLACE_EXISTING);latest=out;report(sender,"Export complete: "+out.toAbsolutePath()+". Run /minegeo upload.");}
|
||||
catch(Exception ex){failure.set(ex);fail(sender,"Export",ex);}finally{done.set(true);busy=false;}
|
||||
});
|
||||
new BukkitRunnable(){int cx=Math.floorDiv(area.minX,16),cz=Math.floorDiv(area.minZ,16),scanned;final int firstX=cx,lastX=Math.floorDiv(area.maxX-1,16),lastZ=Math.floorDiv(area.maxZ-1,16),total=(lastX-firstX+1)*(lastZ-cz+1);
|
||||
@Override public void run(){try{
|
||||
if(cancelled.get()){status="Cancelled";busy=false;cancel();return;}
|
||||
int batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
|
||||
for(int n=0;n<batch&&index<coords.size();n++,index++){int[] c=coords.get(index);if(!world.loadChunk(c[0],c[1],false))continue;Chunk chunk=world.getChunkAt(c[0],c[1]);captured.add(new Captured(c[0],c[1],chunk.getChunkSnapshot(true,true,false)));}
|
||||
status="Snapshotting "+index+"/"+coords.size();
|
||||
if(index>=coords.size()){cancel();status="Encoding terrain";Bukkit.getScheduler().runTaskAsynchronously(MineGeoPlugin.this,()->encode(captured,out,name,lower,upper));}
|
||||
}catch(Exception ex){cancel();busy=false;status="Export failed: "+ex.getMessage();getLogger().warning(status);}}
|
||||
if(cancelled.get()||failure.get()!=null||done.get()){done.set(true);cancel();return;}
|
||||
for(int n=0;n<batch&&cz<=lastZ&&queue.remainingCapacity()>0;n++){
|
||||
if(world.isChunkGenerated(cx,cz)){boolean wasLoaded=world.isChunkLoaded(cx,cz);if(world.loadChunk(cx,cz,false)){try{queue.add(new Captured(cx,cz,world.getChunkAt(cx,cz).getChunkSnapshot(true,true,false)));}finally{if(!wasLoaded)world.unloadChunkRequest(cx,cz);}}}
|
||||
scanned++;if(++cx>lastX){cx=firstX;cz++;}
|
||||
}
|
||||
status="Exporting chunks "+scanned+"/"+total+" — "+out.getFileName();if(cz>lastZ){done.set(true);cancel();}
|
||||
}catch(Exception ex){failure.set(ex);done.set(true);cancel();}}
|
||||
}.runTaskTimer(this,1,1);
|
||||
}
|
||||
private void encode(List<Captured> chunks,Path out,String name,int lower,int upper){
|
||||
try{JsonObject bounds=new JsonObject();bounds.addProperty("minX",minX);bounds.addProperty("minZ",minZ);bounds.addProperty("maxX",maxX);bounds.addProperty("maxZ",maxZ);
|
||||
JsonObject terrain=new JsonObject();terrain.add("bounds",bounds);JsonArray blocks=new JsonArray(),coverage=new JsonArray(),points=new JsonArray();
|
||||
List<int[]> candidates=new ArrayList<>();
|
||||
for(Captured c:chunks){if(cancelled.get())throw new IOException("Cancelled");JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
|
||||
for(int z=0;z<16;z++)for(int x=0;x<16;x++){int wx=c.cx*16+x,wz=c.cz*16+z;if(wx<minX||wx>=maxX||wz<minZ||wz>=maxZ)continue;int top=c.snapshot.getHighestBlockYAt(x,z);for(int y=Math.max(lower,top-24);y<=Math.min(upper-1,top+1);y++){var bd=c.snapshot.getBlockData(x,y,z);if(bd.getMaterial().isAir())continue;JsonArray block=new JsonArray();block.add(wx);block.add(y);block.add(wz);block.add(bd.getAsString());block.add(c.snapshot.getBiome(x,y,z).getKey().toString());blocks.add(block);}if(x==8&&z==8){int y=top;while(y>lower&&!c.snapshot.getBlockData(x,y,z).getMaterial().isSolid())y--;Material ground=c.snapshot.getBlockData(x,y,z).getMaterial();if(ground.isSolid()&&!ground.name().contains("LEAVES")&&c.snapshot.getBlockData(x,y+1,z).getMaterial().isAir()&&c.snapshot.getBlockData(x,y+2,z).getMaterial().isAir())candidates.add(new int[]{wx,y+1,wz});}}
|
||||
private void encode(BlockingQueue<Captured> queue,AtomicBoolean done,AtomicReference<Exception> failure,Path out,String name,String serverName,Area area,int lower,int upper,int desired)throws Exception {
|
||||
JsonObject bounds=new JsonObject();bounds.addProperty("minX",area.minX);bounds.addProperty("minZ",area.minZ);bounds.addProperty("maxX",area.maxX);bounds.addProperty("maxZ",area.maxZ);
|
||||
JsonArray coverage=new JsonArray(),points=new JsonArray();List<int[]> candidates=new ArrayList<>();long blocks=0;Path terrain=out.resolve("terrain.json.gz");
|
||||
try(JsonWriter writer=new JsonWriter(new BufferedWriter(new OutputStreamWriter(new LimitedStream(new GZIPOutputStream(Files.newOutputStream(terrain))),StandardCharsets.UTF_8)))){
|
||||
writer.beginObject().name("bounds");gson.toJson(bounds,writer);writer.name("blocks").beginArray();
|
||||
while(!done.get()||!queue.isEmpty()){
|
||||
if(cancelled.get())throw new IOException("Cancelled");if(failure.get()!=null)throw failure.get();Captured c=queue.poll(250,TimeUnit.MILLISECONDS);if(c==null)continue;
|
||||
JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
|
||||
for(int z=0;z<16;z++)for(int x=0;x<16;x++){
|
||||
int wx=c.cx*16+x,wz=c.cz*16+z;if(wx<area.minX||wx>=area.maxX||wz<area.minZ||wz>=area.maxZ)continue;
|
||||
int top=Math.min(upper-1,Math.max(lower,c.snapshot.getHighestBlockYAt(x,z)));
|
||||
// Skip buried opaque blocks to keep surface exports within the renderer budget.
|
||||
for(int y=Math.max(lower,top-24);y<=top;y++){
|
||||
Material type=c.snapshot.getBlockType(x,y,z);if(type.isAir())continue;
|
||||
if(type.isOccluding()&&x>0&&x<15&&z>0&&z<15&&y>lower&&y+1<upper&&c.snapshot.getBlockType(x-1,y,z).isOccluding()&&c.snapshot.getBlockType(x+1,y,z).isOccluding()&&c.snapshot.getBlockType(x,y,z-1).isOccluding()&&c.snapshot.getBlockType(x,y,z+1).isOccluding()&&c.snapshot.getBlockType(x,y-1,z).isOccluding()&&c.snapshot.getBlockType(x,y+1,z).isOccluding())continue;
|
||||
if(++blocks>5000000)throw new IOException("Export exceeds the 5-million-block renderer limit. Select a smaller area.");
|
||||
writer.beginArray().value(wx).value(y).value(wz).value(c.snapshot.getBlockData(x,y,z).getAsString()).value(c.snapshot.getBiome(x,y,z).getKey().toString()).endArray();
|
||||
}
|
||||
if(x%4==2&&z%4==2){int y=top;while(y>lower&&c.snapshot.getBlockType(x,y,z).isAir())y--;Material ground=c.snapshot.getBlockType(x,y,z);if(y+2<upper&&ground.isOccluding()&&!ground.name().contains("LEAVES")&&c.snapshot.getBlockType(x,y+1,z).isAir()&&c.snapshot.getBlockType(x,y+2,z).isAir())candidates.add(new int[]{wx,y+1,wz});}
|
||||
}
|
||||
}
|
||||
if(candidates.size()<5)throw new IOException("Need at least five safe surface viewpoints; choose a larger area.");
|
||||
int desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12)));for(int i=0;i<Math.min(desired,candidates.size());i++){int[] p=candidates.get(i*candidates.size()/Math.min(desired,candidates.size()));JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);}
|
||||
terrain.add("blocks",blocks);terrain.add("coverage",coverage);Path tmp=out.resolve("terrain.json.gz.tmp");try(Writer writer=new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),java.nio.charset.StandardCharsets.UTF_8)){gson.toJson(terrain,writer);}Files.move(tmp,out.resolve("terrain.json.gz"),StandardCopyOption.REPLACE_EXISTING);
|
||||
JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla terrain exported from "+getConfig().getString("server-name"));manifest.addProperty("serverName",getConfig().getString("server-name"));manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);JsonArray assets=new JsonArray();byte[] bytes=Files.readAllBytes(out.resolve("terrain.json.gz"));JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",bytes.length);asset.addProperty("sha256",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)));assets.add(asset);manifest.add("assets",assets);Files.writeString(out.resolve("manifest.json"),gson.toJson(manifest));status="Export complete: "+blocks.size()+" blocks, "+points.size()+" viewpoints. Edit manifest viewpoints if needed; /minegeo upload.";getLogger().info(status);
|
||||
}catch(Exception ex){status="Export failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}
|
||||
if(failure.get()!=null)throw failure.get();if(cancelled.get())throw new IOException("Cancelled");writer.endArray().name("coverage");gson.toJson(coverage,writer);writer.endObject();
|
||||
}
|
||||
if(coverage.isEmpty())throw new IOException("No generated chunks here. Select an explored area with /minegeo area.");
|
||||
if(candidates.size()<5)throw new IOException("Need five safe, open surface positions. Select a larger land area (not ocean or treetops).");
|
||||
List<int[]> chosen=new ArrayList<>();chosen.add(candidates.remove(candidates.size()/2));
|
||||
while(chosen.size()<desired&&!candidates.isEmpty()){
|
||||
if(cancelled.get())throw new IOException("Cancelled");int best=0;double bestDistance=-1;
|
||||
for(int i=0;i<candidates.size();i++){int[] p=candidates.get(i);double distance=Double.MAX_VALUE;for(int[] q:chosen)distance=Math.min(distance,Math.pow(p[0]-q[0],2)+Math.pow(p[2]-q[2],2));if(distance>bestDistance){bestDistance=distance;best=i;}}chosen.add(candidates.remove(best));
|
||||
}
|
||||
for(int i=0;i<chosen.size();i++){int[] p=chosen.get(i);JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);}
|
||||
if(Files.size(terrain)>50L*1024*1024)throw new IOException("Compressed export exceeds 50 MiB. Select a smaller area.");
|
||||
JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);
|
||||
JsonArray assets=new JsonArray();JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",Files.size(terrain));MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream input=Files.newInputStream(terrain)){byte[] buffer=new byte[65536];int read;while((read=input.read(buffer))!=-1)digest.update(buffer,0,read);}asset.addProperty("sha256",HexFormat.of().formatHex(digest.digest()));assets.add(asset);manifest.add("assets",assets);
|
||||
// The manifest is the completion marker. Partial folders never become upload candidates.
|
||||
if(cancelled.get())throw new IOException("Cancelled");Files.writeString(out.resolve("manifest.json.tmp"),gson.toJson(manifest));Files.move(out.resolve("manifest.json.tmp"),out.resolve("manifest.json"),StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
private static final class LimitedStream extends FilterOutputStream {
|
||||
private long bytes;LimitedStream(OutputStream out){super(out);}
|
||||
@Override public void write(int b)throws IOException{limit(1);out.write(b);}
|
||||
@Override public void write(byte[] b,int off,int len)throws IOException{limit(len);out.write(b,off,len);}
|
||||
private void limit(int n)throws IOException{bytes+=n;if(bytes>240L*1024*1024)throw new IOException("Terrain exceeds the renderer's uncompressed size limit. Select a smaller area.");}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,30 +6,77 @@ import java.nio.file.*;
|
||||
import java.io.*;
|
||||
import java.util.*;
|
||||
import java.time.Duration;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.function.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** TUS resumable upload client shared by plugin and Fabric build. */
|
||||
/** Shared TUS client. State is saved beside each completed local dataset. */
|
||||
public final class UploadClient {
|
||||
private final String website,token;
|
||||
private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
|
||||
// Next's HTTP server does not support Java's cleartext HTTP/2 upgrade handshake.
|
||||
private final HttpClient client=HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(30)).build();
|
||||
private final Gson gson=new Gson();
|
||||
public UploadClient(String website,String token){if(website==null||(!website.startsWith("https://")&&!website.startsWith("http://localhost")&&!website.startsWith("http://127.0.0.1")))throw new IllegalArgumentException("Use HTTPS or local development URL");this.website=website.replaceAll("/$","");this.token=token;}
|
||||
private JsonObject api(String path,String body)throws Exception{var request=HttpRequest.newBuilder(URI.create(website+"/api/v1"+path)).timeout(Duration.ofMinutes(2)).header("Authorization","Bearer "+token).header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build();var response=client.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IOException("MineGeo API "+response.statusCode()+": "+response.body());return JsonParser.parseString(response.body()).getAsJsonObject();}
|
||||
public JsonObject beginPairing()throws Exception{return api("/pairing","{}");}
|
||||
public JsonObject pollPairing(String code)throws Exception{return api("/pairing/poll","{\"deviceCode\":\""+code+"\"}");}
|
||||
public void upload(Path directory)throws Exception{
|
||||
if(token==null||token.isBlank())throw new IOException("Link a contributor account first.");
|
||||
JsonObject manifest=JsonParser.parseString(Files.readString(directory.resolve("manifest.json"))).getAsJsonObject();
|
||||
Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
|
||||
String manifestHash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(gson.toJson(manifest).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
if(!state.has("hash")||!state.get("hash").getAsString().equals(manifestHash)){state=new JsonObject();state.addProperty("hash",manifestHash);JsonObject request=new JsonObject();request.add("manifest",manifest);JsonObject created=api("/uploads",gson.toJson(request));state.addProperty("datasetId",created.get("datasetId").getAsString());Files.writeString(ledger,gson.toJson(state));}
|
||||
String datasetId=state.get("datasetId").getAsString();
|
||||
JsonObject request=new JsonObject();request.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(request));
|
||||
for(JsonElement elem:signed.getAsJsonArray("assets")){JsonObject asset=elem.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();if(!file.startsWith(directory.normalize()))throw new IOException("Unsafe asset path");long length=Files.size(file);String signedToken=asset.get("token").getAsString();String endpoint=asset.get("endpoint").getAsString();String key="url:"+name;String uploadUrl=state.has(key)?state.get(key).getAsString():null;long offset=0;
|
||||
if(uploadUrl!=null){var head=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofSeconds(45)).header("Tus-Resumable","1.0.0").header("x-signature",signedToken).method("HEAD",HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.discarding());if(head.statusCode()==200||head.statusCode()==204)offset=Long.parseLong(head.headers().firstValue("Upload-Offset").orElse("0"));else uploadUrl=null;}
|
||||
if(uploadUrl==null){String metadata="bucketName "+b64("minegeo-raw")+",objectName "+b64(asset.get("objectName").getAsString())+",contentType "+b64("application/octet-stream")+",cacheControl "+b64("3600");var created=client.send(HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(60)).header("Tus-Resumable","1.0.0").header("Upload-Length",Long.toString(length)).header("Upload-Metadata",metadata).header("x-signature",signedToken).POST(HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.ofString());if(created.statusCode()!=201)throw new IOException("TUS create failed: "+created.statusCode()+" "+created.body());uploadUrl=URI.create(endpoint).resolve(created.headers().firstValue("Location").orElseThrow()).toString();state.addProperty(key,uploadUrl);Files.writeString(ledger,gson.toJson(state));}
|
||||
try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){while(offset<length){input.seek(offset);byte[] chunk=new byte[(int)Math.min(6*1024*1024,length-offset)];input.readFully(chunk);var patched=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofMinutes(3)).header("Tus-Resumable","1.0.0").header("Content-Type","application/offset+octet-stream").header("Upload-Offset",Long.toString(offset)).header("x-signature",signedToken).method("PATCH",HttpRequest.BodyPublishers.ofByteArray(chunk)).build(),HttpResponse.BodyHandlers.ofString());if(patched.statusCode()!=204)throw new IOException("TUS patch failed at "+offset+": "+patched.statusCode()+". Run upload again to resume.");offset=Long.parseLong(patched.headers().firstValue("Upload-Offset").orElseThrow());}}
|
||||
}
|
||||
api("/uploads/finalize","{\"datasetId\":\""+datasetId+"\"}");
|
||||
public UploadClient(String website,String token){
|
||||
URI uri;try{uri=URI.create(website==null?"":website.trim());}catch(Exception ex){throw new IllegalArgumentException("Set website to a full http:// or https:// address.");}
|
||||
if(!List.of("http","https").contains(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||uri.getQuery()!=null||uri.getFragment()!=null||!(uri.getPath().isEmpty()||uri.getPath().equals("/")))throw new IllegalArgumentException("Set website to http(s)://hostname:port, without a path. LAN addresses are supported.");
|
||||
this.website=uri.toString().replaceAll("/$","");this.token=token==null?"":token.trim();
|
||||
}
|
||||
private String b64(String s){return Base64.getEncoder().encodeToString(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));}
|
||||
private JsonObject api(String path,String body)throws Exception{
|
||||
var builder=HttpRequest.newBuilder(URI.create(website+"/api/v1"+path)).timeout(Duration.ofMinutes(2)).header("Content-Type","application/json");
|
||||
if(!token.isBlank())builder.header("Authorization","Bearer "+token);
|
||||
var request=body==null?builder.GET().build():builder.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
var response=client.send(request,HttpResponse.BodyHandlers.ofString());
|
||||
if(response.statusCode()<200||response.statusCode()>=300){String detail=response.body();try{var json=JsonParser.parseString(detail).getAsJsonObject();if(json.has("error"))detail=json.get("error").getAsString();}catch(Exception ignored){}if(detail.length()>400)detail=detail.substring(0,400);throw new IOException("MineGeo API "+response.statusCode()+": "+detail+(response.statusCode()/100==3?". Set website to its final HTTPS address.":""));}
|
||||
try{return JsonParser.parseString(response.body()).getAsJsonObject();}catch(Exception ex){throw new IOException("The website returned HTML or invalid JSON. Check the MineGeo website URL.");}
|
||||
}
|
||||
public JsonObject beginPairing()throws Exception{return api("/pairing","{}");}
|
||||
public JsonObject pollPairing(String code)throws Exception{var body=new JsonObject();body.addProperty("deviceCode",code);return api("/pairing/poll",gson.toJson(body));}
|
||||
public void upload(Path directory)throws Exception{upload(directory,message->{},()->false);}
|
||||
private void checkCancelled(BooleanSupplier cancelled)throws IOException{if(cancelled.getAsBoolean()||Thread.currentThread().isInterrupted())throw new IOException("Upload cancelled. Run upload again to resume.");}
|
||||
private void save(Path ledger,JsonObject state)throws IOException{Path tmp=ledger.resolveSibling(ledger.getFileName()+".tmp");Files.writeString(tmp,gson.toJson(state));Files.move(tmp,ledger,StandardCopyOption.REPLACE_EXISTING);}
|
||||
private String hash(byte[] bytes)throws Exception{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
|
||||
public void upload(Path directory,Consumer<String> progress,BooleanSupplier cancelled)throws Exception{
|
||||
if(token.isBlank())throw new IOException("Link a contributor account first.");
|
||||
directory=directory.toAbsolutePath().normalize();Path manifestFile=directory.resolve("manifest.json");
|
||||
if(!Files.isRegularFile(manifestFile))throw new IOException("No completed manifest in "+directory+". Export first, wait for completion, then upload.");
|
||||
JsonObject manifest=JsonParser.parseString(Files.readString(manifestFile)).getAsJsonObject();
|
||||
progress.accept("Checking export files");
|
||||
for(JsonElement element:manifest.getAsJsonArray("assets")){
|
||||
checkCancelled(cancelled);var asset=element.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();
|
||||
if(!file.startsWith(directory)||!Files.isRegularFile(file)||!file.toRealPath().startsWith(directory.toRealPath()))throw new IOException("Missing or unsafe export asset: "+name);
|
||||
if(Files.size(file)!=asset.get("bytes").getAsLong())throw new IOException("Export asset size changed: "+name+". Export again.");
|
||||
MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream in=Files.newInputStream(file)){byte[] buffer=new byte[65536];int n;while((n=in.read(buffer))!=-1){checkCancelled(cancelled);digest.update(buffer,0,n);}}
|
||||
if(!HexFormat.of().formatHex(digest.digest()).equals(asset.get("sha256").getAsString()))throw new IOException("Export asset checksum changed: "+name+". Export again.");
|
||||
}
|
||||
Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
|
||||
String fingerprint=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
|
||||
if(!state.has("hash")||!state.get("hash").getAsString().equals(fingerprint)){
|
||||
state=new JsonObject();state.addProperty("hash",fingerprint);JsonObject body=new JsonObject();body.add("manifest",manifest);progress.accept("Creating private draft");checkCancelled(cancelled);
|
||||
JsonObject created=api("/uploads",gson.toJson(body));state.addProperty("datasetId",created.get("datasetId").getAsString());save(ledger,state);
|
||||
}
|
||||
String datasetId=state.get("datasetId").getAsString();
|
||||
JsonObject existing=api("/datasets/"+datasetId,null);String stage=existing.get("state").getAsString();
|
||||
if(!stage.equals("uploading")){
|
||||
if(stage.equals("failed"))throw new IOException("The website could not process this export. Check its dashboard error; create a new export after fixing the cause.");
|
||||
progress.accept("Already uploaded — draft "+datasetId+" ("+stage+")");return;
|
||||
}
|
||||
JsonObject body=new JsonObject();body.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(body));int index=0,count=signed.getAsJsonArray("assets").size();
|
||||
for(JsonElement elem:signed.getAsJsonArray("assets")){
|
||||
checkCancelled(cancelled);index++;JsonObject asset=elem.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();if(!file.startsWith(directory))throw new IOException("Unsafe asset path");
|
||||
String publishableKey=asset.has("apiKey")?asset.get("apiKey").getAsString():"";long length=Files.size(file);String signedToken=asset.get("token").getAsString(),endpoint=asset.get("endpoint").getAsString(),key="url:"+name;
|
||||
String uploadUrl=state.has(key)?state.get(key).getAsString():null;long offset=0;
|
||||
if(uploadUrl!=null){var head=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofSeconds(45)).header("Tus-Resumable","1.0.0").header("x-signature",signedToken).header("apikey",publishableKey).method("HEAD",HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.discarding());if(head.statusCode()==200||head.statusCode()==204)offset=Long.parseLong(head.headers().firstValue("Upload-Offset").orElseThrow());else if(head.statusCode()==404||head.statusCode()==410)uploadUrl=null;else throw new IOException("Upload resume failed (HTTP "+head.statusCode()+"). Retry upload.");}
|
||||
if(uploadUrl==null){String metadata="bucketName "+b64("minegeo-raw")+",objectName "+b64(asset.get("objectName").getAsString())+",contentType "+b64("application/octet-stream")+",cacheControl "+b64("3600");var created=client.send(HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(60)).header("Tus-Resumable","1.0.0").header("Upload-Length",Long.toString(length)).header("Upload-Metadata",metadata).header("x-signature",signedToken).header("apikey",publishableKey).POST(HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.ofString());if(created.statusCode()!=201)throw new IOException("Storage upload creation failed (HTTP "+created.statusCode()+"). "+created.body());uploadUrl=URI.create(endpoint).resolve(created.headers().firstValue("Location").orElseThrow()).toString();state.addProperty(key,uploadUrl);save(ledger,state);offset=0;}
|
||||
if(offset<0||offset>length)throw new IOException("Storage returned an invalid upload offset.");
|
||||
try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){
|
||||
while(offset<length){checkCancelled(cancelled);progress.accept("Uploading "+index+"/"+count+" · "+name+" · "+(100*offset/length)+"%");input.seek(offset);byte[] chunk=new byte[(int)Math.min(6*1024*1024,length-offset)];input.readFully(chunk);
|
||||
var patched=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofMinutes(3)).header("Tus-Resumable","1.0.0").header("Content-Type","application/offset+octet-stream").header("Upload-Offset",Long.toString(offset)).header("x-signature",signedToken).header("apikey",publishableKey).method("PATCH",HttpRequest.BodyPublishers.ofByteArray(chunk)).build(),HttpResponse.BodyHandlers.ofString());
|
||||
if(patched.statusCode()!=204)throw new IOException("Upload paused at "+offset+" bytes (HTTP "+patched.statusCode()+"). Run upload again to resume.");
|
||||
long next=Long.parseLong(patched.headers().firstValue("Upload-Offset").orElseThrow());if(next!=offset+chunk.length)throw new IOException("Storage returned an unexpected offset. Retry upload to resume.");offset=next;
|
||||
}
|
||||
}
|
||||
}
|
||||
checkCancelled(cancelled);progress.accept("Upload complete; queueing panorama processing");api("/uploads/finalize",gson.toJson(body));
|
||||
}
|
||||
private String b64(String s){return Base64.getEncoder().encodeToString(s.getBytes(StandardCharsets.UTF_8));}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: MineGeo
|
||||
version: 0.1.0
|
||||
version: 0.1.1
|
||||
main: dev.minegeo.plugin.MineGeoPlugin
|
||||
api-version: '1.21'
|
||||
description: Bounded terrain export for MineGeo panorama guessing
|
||||
|
||||
Reference in New Issue
Block a user