Adding /script download (#826)

This reverts commit e98adb77a52d1e274ec320e0cafab34b24c1c738.

* First attempt at creating download system...

* Last try, I had uncommitted changes which I forgot to add.

If it fails now i swear imma delete my fork of fabric carpet.

* OK, this is the last chance. I have checked and double checked, this should not fail.

Todo make them more detailed, Im not the best person to do that tbh...

* Towards a suggestion builder

(Also committing stuff in ScriptDownloader incase it crashes)

* Finished first theoretically functional draft of suggestion builder

* improving suggestion building with actual github api

* Collapsing identical error messages

* Got suggestions to work!

Now gotta get the actual file to save to disk properly

* replacing whitespace with url whitespace to make it read form 'creativ building tools' etc

* Successfully downloaded script into folder location!

* only clients can access global scripts folder, so don't want to cause error message

* Compacting code and updating docs

* Fixed getting folders from file names
This commit is contained in:
Ghoulboy authored and GitHub committed 2021-05-19 17:30:39 -03:00
1 parent f56b22be6a
commit 5f16ffd933
3 files changed
+267 -8

No files matched your search

+8
View File
@@ -167,3 +167,11 @@ This would slow down the computation of fib(40) from a minute to two, but allows
and be responsive to commands, using about half of each tick to advance the computation. Obviously depending on the
problem, and available hardware, certain things can take more or less time to execute, so portioning of work with
calling `gametick` should be balanced in each case separately
# `/script download` command
`/script download <global or local?> <path>` command allows to download and save scripts directly from [scarpet app store](https://www.github.com/gnembon/scarpet).
You can choose whether to save the script in the global config scripts folder or the local per-world script folder,
(on servers it always saves in the scripts folder) and it will be saved there, e.g `/script download local survival/angel_block.sc`
would save the `angel_block.sc` file [here](https://github.com/gnembon/scarpet/blob/master/programs/survival/angel_block.sc)
to a folder called `appstore/survival` in the world's scripts folder.
@@ -2,6 +2,7 @@ package carpet.commands;
import carpet.CarpetServer;
import carpet.CarpetSettings;
import carpet.helpers.ScriptDownloader;
import carpet.script.CarpetEventServer;
import carpet.script.CarpetExpression;
import carpet.script.CarpetScriptHost;
@@ -31,18 +32,13 @@ import net.minecraft.command.argument.BlockStateArgumentType;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.block.entity.BlockEntity;
import net.minecraft.util.Clearable;
import net.minecraft.util.Pair;
import net.minecraft.util.math.BlockPos;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.math.BlockBox;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import java.util.function.Supplier;
@@ -61,6 +57,7 @@ public class ScriptCommand
Set<String> allFunctions = (new CarpetExpression(null, "null", null, null)).getExpr().getFunctionNames();
scarpetFunctions = new TreeSet<>(Expression.none.getFunctionNames());
APIFunctions = allFunctions.stream().filter(s -> !scarpetFunctions.contains(s)).collect(Collectors.toCollection(TreeSet::new));
ScriptDownloader.updateLocalRepoStructure();
}
public static List<String> suggestFunctions(ScriptHost host, String previous, String prefix)
@@ -105,6 +102,23 @@ public class ScriptCommand
return suggestionsBuilder.buildFuture();
}
/**
* A method to suggest the available scarpet scripts based off of the current player input and {@link ScriptDownloader#localScarpetRepoStructure}
* variable.
*/
private static CompletableFuture<Suggestions> suggestDownloadableApps(
CommandContext<ServerCommandSource> context,
SuggestionsBuilder suggestionsBuilder
) {
String previous = suggestionsBuilder.getRemaining();
Pair<String, Set<String>> suggestions = ScriptDownloader.fileNamesFromPath(previous);
String currentValidPath = suggestions.getLeft();
suggestions.getRight().forEach(s -> suggestionsBuilder.suggest(currentValidPath + s));
return suggestionsBuilder.buildFuture();
}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher)
{
LiteralArgumentBuilder<ServerCommandSource> b = literal("globals").
@@ -302,10 +316,13 @@ public class ScriptCommand
StringArgumentType.getString(cc, "call")
)?1:0))));
LiteralArgumentBuilder<ServerCommandSource> d = literal("download").requires((player) -> SettingsManager.canUseCommand(player, CarpetSettings.commandScriptACE)).
then(literal("global").then(argument("path", StringArgumentType.greedyString()).suggests(ScriptCommand::suggestDownloadableApps).executes(cc->ScriptDownloader.downloadScript(cc, StringArgumentType.getString(cc,"path"), true)))).
then(literal("local").then(argument("path", StringArgumentType.greedyString()).suggests(ScriptCommand::suggestDownloadableApps).executes(cc->ScriptDownloader.downloadScript(cc, StringArgumentType.getString(cc,"path"), false))));
dispatcher.register(literal("script").
requires((player) -> SettingsManager.canUseCommand(player, CarpetSettings.commandScript)).
then(b).then(u).then(o).then(l).then(s).then(c).then(h).then(i).then(e).then(t).then(a).then(f).then(q));
then(b).then(u).then(o).then(l).then(s).then(c).then(h).then(i).then(e).then(t).then(a).then(f).then(q).then(d));
dispatcher.register(literal("script").
requires((player) -> SettingsManager.canUseCommand(player, CarpetSettings.commandScript)).
then(literal("in").
@@ -0,0 +1,234 @@
package carpet.helpers;
import carpet.utils.Messenger;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.brigadier.context.CommandContext;
import net.fabricmc.api.EnvType;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.command.CommandException;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.text.LiteralText;
import net.minecraft.util.Pair;
import net.minecraft.util.WorldSavePath;
import java.io.BufferedReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* A class used to save scarpet app store scripts to disk
*/
@SuppressWarnings("rawtypes")
public class ScriptDownloader {
/** A local copy of the scarpet repo's file structure, to avoid multiple queries to github.com while typing out the
* {@code /script download} command and getting the suggestions. Therefore, we save the file structure at the beginning
* of the server's starting, and update it with {@link ScriptDownloader#updateLocalRepoStructure()} if we get an error
* in the query, thereby allowing us to re-enter the command and get the correct app (unless of course the inputted
* path is itself wrong)
*/
public static Map<String, Map> localScarpetRepoStructure = new HashMap<>();
/** This is the link to the scarpet app repo from the github api, so if that ever changes then this variable would
* have to change as well to match.
*/
private static final String scarpetRepoLink = "https://api.github.com/repos/gnembon/scarpet/contents/programs/";
/** A simple shorthand for calling the {@link ScriptDownloader#getScriptCode} and {@link ScriptDownloader#saveScriptToFile}
* methods to avoid repeating code and so it makes more sense what it's exactly doing.
*
* @param path The user-inputted path to the script
* @param global Whether or not we wanna save it to the local scripts folder or in the global config folder
* @return {@code 1} if we succesfully saved the script, {@code 0} otherwise
*/
public static int downloadScript(CommandContext<ServerCommandSource> cc, String path, boolean global){
String code = getScriptCode(path);
return saveScriptToFile(path, code, cc, global);
}
/** Gets the code once the user inputs the command. The code isn't saved in {@link ScriptDownloader#localScarpetRepoStructure}
* as the scarpet repo is very large and may get much larger in the future, and that may cause RAM issues if we have
* the entire thing saved in memory.
*
* @param path The user inputted path to the scarpet script
* @return the HTML request from the path program using {@link ScriptDownloader#getStringFromStream} method to convert
* HTML response into a string
*/
public static String getScriptCode(String path){
try {
String link = "https://raw.githubusercontent.com/gnembon/scarpet/master/programs/"+ path.replace(" ", "%20");
URL appURL = new URL(link);
HttpURLConnection http = (HttpURLConnection) appURL.openConnection();
return getStringFromStream((InputStream) http.getContent());
} catch (IOException e){//todo add checks to distinguish between incorrect file path or change in scarpet repo structure
throw new CommandException(new LiteralText("'"+ path + "' is not a valid path to a scarpet app"));
}
}
public static int saveScriptToFile(String name, String code, CommandContext<ServerCommandSource> cc, boolean globalSavePath){
Path scriptLocation;
String location;
if(globalSavePath && FabricLoader.getInstance().getEnvironmentType()== EnvType.CLIENT) {//cos config folder only is in clients
scriptLocation = FabricLoader.getInstance().getConfigDir().resolve("carpet/scripts/appstore");
location = "global script config folder";
} else {
scriptLocation = cc.getSource().getMinecraftServer().getSavePath(WorldSavePath.ROOT).resolve("scripts/appstore");
location = "world scripts folder";
}
try {
String scriptPath = scriptLocation.toAbsolutePath() + "/" + name;
scriptLocation = Paths.get(scriptPath);
Path scriptFolderLocation = Paths.get(scriptPath.substring(0, scriptPath.lastIndexOf('/')));//folder location without file name
if (!Files.exists(scriptFolderLocation)) {
Files.createDirectories(scriptFolderLocation);
}
if(Files.exists(scriptLocation)){
Messenger.m(cc.getSource(), String.format("gi Note: overwriting existing file '%s'", name));
}
Messenger.m(cc.getSource(), "gi Placing script in " + location);
FileWriter fileWriter = new FileWriter(scriptPath);
fileWriter.write(code);
fileWriter.close();
} catch (IOException e) {
Messenger.m(cc.getSource(), "r Error in downloading script");
return 0;
}
Messenger.m(cc.getSource(), "gi Successfully created "+ name + " in " + location);
return 1;
}
/** Returns the string from the inputstream gotten from the html request.
* Thanks to App Shah in <a href="https://crunchify.com/in-java-how-to-read-github-file-contents-using-httpurlconnection-convert-stream-to-string-utility/">this post</a>
* for this code.
*
* @return the string input from the InputStream
* @throws IOException if an I/O error occurs
*/
public static String getStringFromStream(InputStream inputStream) throws IOException {
if (inputStream != null) {
Writer stringWriter = new StringWriter();
char[] charBuffer = new char[2048];
try{
Reader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
int counter;
while ((counter = reader.read(charBuffer)) != -1) {
stringWriter.write(charBuffer, 0, counter);
}
} finally {
inputStream.close();
}
return stringWriter.toString();
} else {
return "";
}
}
/** Updates local copy of scarpet repo, giving an error otherwise which ought to be reported to fabric-carpet devs,
* in particular Ghoulboy if possible as he wrote this code.
*
* @author Ghoulboy
*/
public static void updateLocalRepoStructure(){
try{
localScarpetRepoStructure = updateLocalRepoStructure("");
} catch (IOException | IllegalStateException exc){//should not happen as long as repo name stays the same
System.out.println("ERROR: ScriptDownloader#scarpetRepoLink variable is out of date, please update your carpet version, and if the problem persists please submit a bug report here: https://github.com/gnembon/fabric-carpet/issues/new");
throw new CommandException(new LiteralText("Internal scarpet app store repo structure changed, please contact fabric-carpet developers, enclosing a copy of the server log!"));
}
}
/** A DFS to save the file structure (not the code) in the scarpet repo, called at server start and whenever running
* the command {@code /script download} command causes an oopsie, so the internal scarpet repo structure is changed
* and we therefore need to update it. We don't save the code here in case it needs updating from the last time, and
* also not to slow down the search for
*
* @param currentPath The current path down which we are looking for code.
* @return The scarpet repo structure as a map which you navigate down, so you don't need to query to github API
* multiple times
* @throws IOException only if {@link ScriptDownloader#scarpetRepoLink} structure is out of date, i.e scarpet internal
* repo structure has changed
*/
public static Map<String, Map> updateLocalRepoStructure(String currentPath) throws IOException{
Map<String, Map> ret = new HashMap<>();
String queryPath = scarpetRepoLink + currentPath;
URL appURL = new URL(queryPath);
String response = ScriptDownloader.getStringFromStream(appURL.openStream());
JsonArray files = new JsonParser().parse(response).getAsJsonArray();
for(JsonElement je : files){
JsonObject jo = je.getAsJsonObject();
String name = jo.get("name").getAsString();
String filePath = jo.get("path").getAsString().substring(9).replace(" ", "%20");
if (jo.get("type").getAsString().equals("dir") && !name.contains("shared")) {//cos that may be a directory for shared data, so we don't wanna search there
ret.put(name, updateLocalRepoStructure(filePath));
} else if (name.matches("(\\w+\\.scl?)")){
ret.put(name, null);
}
}
return ret;
}
/** This method searches for valid file names from the user-inputted string, e.g if the user has thus far typed
* {@code survival/a} then it will return all the files in the {@code survival} directory of the scarpet repo (and
* will automatically highlight those starting with a), and the string {@code survival/} as the current most valid path.
*
* @param currentPath The path down which we want to search for files
* @return A pair of the current valid path, as well as the set of all the file/directory names at the end of that path
*/
public static Pair<String, Set<String>> fileNamesFromPath(String currentPath){
String[] path = currentPath.split("/");
Map<String, Map> currentLookedAtFiles = localScarpetRepoStructure;
StringBuilder currentValidPath = new StringBuilder();
for(String folder : path){
if(!currentLookedAtFiles.containsKey(folder) || folder.matches(".+\\.sc"))
return new Pair<>(currentValidPath.toString(), currentLookedAtFiles.keySet());
currentValidPath.append(folder).append("/");
Map newFolder = currentLookedAtFiles.get(folder);
if(newFolder == null)
return new Pair<>(currentValidPath.toString(), currentLookedAtFiles.keySet());
currentLookedAtFiles = newFolder;
}
return new Pair<>(currentValidPath.toString(), currentLookedAtFiles.keySet());
}
}