Accessors, recipe builders
This commit is contained in:
parent
cab98a75c2
commit
834389712b
10 changed files with 524 additions and 1 deletions
|
@ -0,0 +1,15 @@
|
|||
package ru.bclib.mixin.common;
|
||||
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Invoker;
|
||||
|
||||
import net.minecraft.world.level.ItemLike;
|
||||
import net.minecraft.world.level.block.ComposterBlock;
|
||||
|
||||
@Mixin(ComposterBlock.class)
|
||||
public interface ComposterBlockAccessor {
|
||||
@Invoker
|
||||
static void callAdd(float levelIncreaseChance, ItemLike item) {
|
||||
throw new AssertionError("@Invoker dummy body called");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package ru.bclib.mixin.common;
|
||||
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Invoker;
|
||||
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.alchemy.Potion;
|
||||
import net.minecraft.world.item.alchemy.PotionBrewing;
|
||||
|
||||
@Mixin(PotionBrewing.class)
|
||||
public interface PotionBrewingAccessor {
|
||||
@Invoker
|
||||
static void callAddMix(Potion input, Item item, Potion output) {
|
||||
throw new AssertionError("@Invoker dummy body called");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package ru.bclib.mixin.common;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.crafting.Recipe;
|
||||
import net.minecraft.world.item.crafting.RecipeManager;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
|
||||
@Mixin(RecipeManager.class)
|
||||
public interface RecipeManagerAccessor {
|
||||
@Accessor("recipes")
|
||||
Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> be_getRecipes();
|
||||
|
||||
@Accessor("recipes")
|
||||
void be_setRecipes(Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> recipes);
|
||||
}
|
64
src/main/java/ru/bclib/mixin/common/RecipeManagerMixin.java
Normal file
64
src/main/java/ru/bclib/mixin/common/RecipeManagerMixin.java
Normal file
|
@ -0,0 +1,64 @@
|
|||
package ru.bclib.mixin.common;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Overwrite;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
|
||||
import net.minecraft.Util;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.crafting.Recipe;
|
||||
import net.minecraft.world.item.crafting.RecipeManager;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.minecraft.world.level.Level;
|
||||
import ru.bclib.recipes.BCLRecipeManager;
|
||||
|
||||
@Mixin(RecipeManager.class)
|
||||
public abstract class RecipeManagerMixin {
|
||||
@Shadow
|
||||
private Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> recipes;
|
||||
|
||||
@Inject(method = "apply", at = @At(value = "RETURN"))
|
||||
private void be_apply(Map<ResourceLocation, JsonElement> map, ResourceManager resourceManager, ProfilerFiller profiler, CallbackInfo info) {
|
||||
recipes = BCLRecipeManager.getMap(recipes);
|
||||
}
|
||||
|
||||
@Shadow
|
||||
private <C extends Container, T extends Recipe<C>> Map<ResourceLocation, Recipe<C>> byType(RecipeType<T> type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author paulevs
|
||||
* @reason Remove conflicts with vanilla tags
|
||||
* Change recipe order to show mod recipes first, helps when block have vanilla tag
|
||||
* (example - mod stone with vanilla tags and furnace from that stone)
|
||||
*/
|
||||
@Overwrite
|
||||
public <C extends Container, T extends Recipe<C>> Optional<T> getRecipeFor(RecipeType<T> type, C inventory, Level world) {
|
||||
Collection<Recipe<C>> values = byType(type).values();
|
||||
List<Recipe<C>> list = new ArrayList<Recipe<C>>(values);
|
||||
list.sort((v1, v2) -> {
|
||||
boolean b1 = v1.getId().getNamespace().equals("minecraft");
|
||||
boolean b2 = v2.getId().getNamespace().equals("minecraft");
|
||||
return b1 ^ b2 ? (b1 ? 1 : -1) : 0;
|
||||
});
|
||||
|
||||
return list.stream().flatMap((recipe) -> {
|
||||
return Util.toStream(type.tryMatch(recipe, world, inventory));
|
||||
}).findFirst();
|
||||
}
|
||||
}
|
93
src/main/java/ru/bclib/recipes/BCLRecipeManager.java
Normal file
93
src/main/java/ru/bclib/recipes/BCLRecipeManager.java
Normal file
|
@ -0,0 +1,93 @@
|
|||
package ru.bclib.recipes;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
|
||||
import com.google.common.collect.Maps;
|
||||
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.crafting.Recipe;
|
||||
import net.minecraft.world.item.crafting.RecipeSerializer;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.minecraft.world.level.ItemLike;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
|
||||
public class BCLRecipeManager {
|
||||
private static final Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> RECIPES = Maps.newHashMap();
|
||||
|
||||
public static void addRecipe(RecipeType<?> type, Recipe<?> recipe) {
|
||||
Map<ResourceLocation, Recipe<?>> list = RECIPES.get(type);
|
||||
if (list == null) {
|
||||
list = Maps.newHashMap();
|
||||
RECIPES.put(type, list);
|
||||
}
|
||||
list.put(recipe.getId(), recipe);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends Recipe<?>> T getRecipe(RecipeType<T> type, ResourceLocation id) {
|
||||
if (RECIPES.containsKey(type)) {
|
||||
return (T) RECIPES.get(type).get(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> getMap(Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> recipes) {
|
||||
Map<RecipeType<?>, Map<ResourceLocation, Recipe<?>>> result = Maps.newHashMap();
|
||||
|
||||
for (RecipeType<?> type : recipes.keySet()) {
|
||||
Map<ResourceLocation, Recipe<?>> typeList = Maps.newHashMap();
|
||||
typeList.putAll(recipes.get(type));
|
||||
result.put(type, typeList);
|
||||
}
|
||||
|
||||
for (RecipeType<?> type : RECIPES.keySet()) {
|
||||
Map<ResourceLocation, Recipe<?>> list = RECIPES.get(type);
|
||||
if (list != null) {
|
||||
Map<ResourceLocation, Recipe<?>> typeList = result.get(type);
|
||||
if (typeList == null) {
|
||||
typeList = Maps.newHashMap();
|
||||
result.put(type, typeList);
|
||||
}
|
||||
for (Entry<ResourceLocation, Recipe<?>> entry : list.entrySet()) {
|
||||
ResourceLocation id = entry.getKey();
|
||||
if (!typeList.containsKey(id))
|
||||
typeList.put(id, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static <S extends RecipeSerializer<T>, T extends Recipe<?>> S registerSerializer(String modID, String id, S serializer) {
|
||||
return Registry.register(Registry.RECIPE_SERIALIZER, modID + ":" + id, serializer);
|
||||
}
|
||||
|
||||
public static <T extends Recipe<?>> RecipeType<T> registerType(String modID, String type) {
|
||||
ResourceLocation recipeTypeId = new ResourceLocation(modID, type);
|
||||
return Registry.register(Registry.RECIPE_TYPE, recipeTypeId, new RecipeType<T>() {
|
||||
public String toString() {
|
||||
return type;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean exists(ItemLike item) {
|
||||
if (item instanceof Block) {
|
||||
return Registry.BLOCK.getKey((Block) item) != Registry.BLOCK.getDefaultKey();
|
||||
} else {
|
||||
return Registry.ITEM.getKey(item.asItem()) != Registry.ITEM.getDefaultKey();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean exists(ItemLike... items) {
|
||||
for (ItemLike item : items) {
|
||||
if (!exists(item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
96
src/main/java/ru/bclib/recipes/FurnaceRecipe.java
Normal file
96
src/main/java/ru/bclib/recipes/FurnaceRecipe.java
Normal file
|
@ -0,0 +1,96 @@
|
|||
package ru.bclib.recipes;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.crafting.BlastingRecipe;
|
||||
import net.minecraft.world.item.crafting.CampfireCookingRecipe;
|
||||
import net.minecraft.world.item.crafting.Ingredient;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.minecraft.world.item.crafting.SmeltingRecipe;
|
||||
import net.minecraft.world.item.crafting.SmokingRecipe;
|
||||
import net.minecraft.world.level.ItemLike;
|
||||
import ru.bclib.BCLib;
|
||||
|
||||
public class FurnaceRecipe {
|
||||
private static final FurnaceRecipe INSTANCE = new FurnaceRecipe();
|
||||
|
||||
private ResourceLocation id;
|
||||
private ItemLike input;
|
||||
private ItemLike output;
|
||||
private boolean exist;
|
||||
private String group;
|
||||
private int count;
|
||||
private int time;
|
||||
private float xp;
|
||||
|
||||
private FurnaceRecipe() {}
|
||||
|
||||
public static FurnaceRecipe make(String modID, String name, ItemLike input, ItemLike output) {
|
||||
INSTANCE.id = new ResourceLocation(modID, name);
|
||||
INSTANCE.group = "";
|
||||
INSTANCE.input = input;
|
||||
INSTANCE.output = output;
|
||||
INSTANCE.count = 1;
|
||||
INSTANCE.time = 200;
|
||||
INSTANCE.xp = 0;
|
||||
INSTANCE.exist = BCLRecipeManager.exists(output) && BCLRecipeManager.exists(input);
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public FurnaceRecipe setGroup(String group) {
|
||||
this.group = group;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FurnaceRecipe setOutputCount(int count) {
|
||||
this.count = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FurnaceRecipe setXP(float xp) {
|
||||
this.xp = xp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FurnaceRecipe setCookTime(int time) {
|
||||
this.time = time;
|
||||
return this;
|
||||
}
|
||||
|
||||
public void build() {
|
||||
build(false, false, false);
|
||||
}
|
||||
|
||||
public void buildWithBlasting() {
|
||||
build(true, false, false);
|
||||
}
|
||||
|
||||
public void buildFoodlike() {
|
||||
build(false, true, true);
|
||||
}
|
||||
|
||||
public void build(boolean blasting, boolean campfire, boolean smoker) {
|
||||
if (exist) {
|
||||
SmeltingRecipe recipe = new SmeltingRecipe(id, group, Ingredient.of(input), new ItemStack(output, count), xp, time);
|
||||
BCLRecipeManager.addRecipe(RecipeType.SMELTING, recipe);
|
||||
|
||||
if (blasting) {
|
||||
BlastingRecipe recipe2 = new BlastingRecipe(id, group, Ingredient.of(input), new ItemStack(output, count), xp, time / 2);
|
||||
BCLRecipeManager.addRecipe(RecipeType.BLASTING, recipe2);
|
||||
}
|
||||
|
||||
if (campfire) {
|
||||
CampfireCookingRecipe recipe2 = new CampfireCookingRecipe(id, group, Ingredient.of(input), new ItemStack(output, count), xp, time * 3);
|
||||
BCLRecipeManager.addRecipe(RecipeType.CAMPFIRE_COOKING, recipe2);
|
||||
}
|
||||
|
||||
if (smoker) {
|
||||
SmokingRecipe recipe2 = new SmokingRecipe(id, group, Ingredient.of(input), new ItemStack(output, count), xp, time / 2);
|
||||
BCLRecipeManager.addRecipe(RecipeType.SMOKING, recipe2);
|
||||
}
|
||||
}
|
||||
else {
|
||||
BCLib.LOGGER.debug("Furnace recipe {} couldn't be added", id);
|
||||
}
|
||||
}
|
||||
}
|
120
src/main/java/ru/bclib/recipes/GridRecipe.java
Normal file
120
src/main/java/ru/bclib/recipes/GridRecipe.java
Normal file
|
@ -0,0 +1,120 @@
|
|||
package ru.bclib.recipes;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import com.google.common.collect.Maps;
|
||||
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.tags.Tag;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.crafting.CraftingRecipe;
|
||||
import net.minecraft.world.item.crafting.Ingredient;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.minecraft.world.item.crafting.ShapedRecipe;
|
||||
import net.minecraft.world.item.crafting.ShapelessRecipe;
|
||||
import net.minecraft.world.level.ItemLike;
|
||||
import ru.bclib.BCLib;
|
||||
|
||||
public class GridRecipe {
|
||||
private static final GridRecipe INSTANCE = new GridRecipe();
|
||||
|
||||
private ResourceLocation id;
|
||||
private ItemLike output;
|
||||
|
||||
private String group;
|
||||
private RecipeType<?> type;
|
||||
private boolean shaped;
|
||||
private String[] shape;
|
||||
private Map<Character, Ingredient> materialKeys = Maps.newHashMap();
|
||||
private int count;
|
||||
private boolean exist = true;
|
||||
|
||||
private GridRecipe() {}
|
||||
|
||||
public static GridRecipe make(String modID, String name, ItemLike output) {
|
||||
INSTANCE.id = new ResourceLocation(modID, name);
|
||||
INSTANCE.output = output;
|
||||
|
||||
INSTANCE.group = "";
|
||||
INSTANCE.type = RecipeType.CRAFTING;
|
||||
INSTANCE.shaped = true;
|
||||
INSTANCE.shape = new String[] {"#"};
|
||||
INSTANCE.materialKeys.clear();
|
||||
INSTANCE.count = 1;
|
||||
|
||||
INSTANCE.exist = BCLRecipeManager.exists(output);
|
||||
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public GridRecipe setGroup(String group) {
|
||||
this.group = group;
|
||||
return this;
|
||||
}
|
||||
|
||||
public GridRecipe setShape(String... shape) {
|
||||
this.shape = shape;
|
||||
return this;
|
||||
}
|
||||
|
||||
public GridRecipe setList(String shape) {
|
||||
this.shape = new String[] { shape };
|
||||
this.shaped = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
public GridRecipe addMaterial(char key, Tag<Item> value) {
|
||||
return addMaterial(key, Ingredient.of(value));
|
||||
}
|
||||
|
||||
public GridRecipe addMaterial(char key, ItemStack... value) {
|
||||
return addMaterial(key, Ingredient.of(Arrays.stream(value)));
|
||||
}
|
||||
|
||||
public GridRecipe addMaterial(char key, ItemLike... values) {
|
||||
for (ItemLike item: values) {
|
||||
exist &= BCLRecipeManager.exists(item);
|
||||
}
|
||||
return addMaterial(key, Ingredient.of(values));
|
||||
}
|
||||
|
||||
private GridRecipe addMaterial(char key, Ingredient value) {
|
||||
materialKeys.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public GridRecipe setOutputCount(int count) {
|
||||
this.count = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
private NonNullList<Ingredient> getMaterials(int width, int height) {
|
||||
NonNullList<Ingredient> materials = NonNullList.withSize(width * height, Ingredient.EMPTY);
|
||||
int pos = 0;
|
||||
for (String line: shape) {
|
||||
for (int i = 0; i < width; i++) {
|
||||
char c = line.charAt(i);
|
||||
Ingredient material = materialKeys.get(c);
|
||||
materials.set(pos ++, material == null ? Ingredient.EMPTY : material);
|
||||
}
|
||||
}
|
||||
return materials;
|
||||
}
|
||||
|
||||
public void build() {
|
||||
if (exist) {
|
||||
int height = shape.length;
|
||||
int width = shape[0].length();
|
||||
ItemStack result = new ItemStack(output, count);
|
||||
NonNullList<Ingredient> materials = this.getMaterials(width, height);
|
||||
|
||||
CraftingRecipe recipe = shaped ? new ShapedRecipe(id, group, width, height, materials, result) : new ShapelessRecipe(id, group, result, materials);
|
||||
BCLRecipeManager.addRecipe(type, recipe);
|
||||
} else {
|
||||
BCLib.LOGGER.debug("Recipe {} couldn't be added", id);
|
||||
}
|
||||
}
|
||||
}
|
95
src/main/java/ru/bclib/recipes/SmithingTableRecipe.java
Normal file
95
src/main/java/ru/bclib/recipes/SmithingTableRecipe.java
Normal file
|
@ -0,0 +1,95 @@
|
|||
package ru.bclib.recipes;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.tags.Tag;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.crafting.Ingredient;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.minecraft.world.item.crafting.UpgradeRecipe;
|
||||
import net.minecraft.world.level.ItemLike;
|
||||
import ru.bclib.BCLib;
|
||||
|
||||
public class SmithingTableRecipe {
|
||||
|
||||
private final static SmithingTableRecipe BUILDER = new SmithingTableRecipe();
|
||||
private final static RecipeType<UpgradeRecipe> TYPE = RecipeType.SMITHING;
|
||||
|
||||
public static SmithingTableRecipe create(String modID, String name) {
|
||||
return create(new ResourceLocation(modID, name));
|
||||
}
|
||||
|
||||
public static SmithingTableRecipe create(ResourceLocation id) {
|
||||
BUILDER.id = id;
|
||||
BUILDER.base = null;
|
||||
BUILDER.addition = null;
|
||||
BUILDER.result = null;
|
||||
BUILDER.alright = true;
|
||||
|
||||
return BUILDER;
|
||||
}
|
||||
|
||||
private ResourceLocation id;
|
||||
private Ingredient base;
|
||||
private Ingredient addition;
|
||||
private ItemStack result;
|
||||
private boolean alright;
|
||||
|
||||
private SmithingTableRecipe() {}
|
||||
|
||||
public SmithingTableRecipe setResult(ItemLike item) {
|
||||
return this.setResult(item, 1);
|
||||
}
|
||||
|
||||
public SmithingTableRecipe setResult(ItemLike item, int count) {
|
||||
this.alright &= BCLRecipeManager.exists(item);
|
||||
this.result = new ItemStack(item, count);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SmithingTableRecipe setBase(ItemLike... items) {
|
||||
this.alright &= BCLRecipeManager.exists(items);
|
||||
this.base = Ingredient.of(items);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SmithingTableRecipe setBase(Tag<Item> tag) {
|
||||
this.base = (Ingredient.of(tag));
|
||||
return this;
|
||||
}
|
||||
|
||||
public SmithingTableRecipe setAddition(ItemLike... items) {
|
||||
this.alright &= BCLRecipeManager.exists(items);
|
||||
this.addition = Ingredient.of(items);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SmithingTableRecipe setAddition(Tag<Item> tag) {
|
||||
this.addition = (Ingredient.of(tag));
|
||||
return this;
|
||||
}
|
||||
|
||||
public void build() {
|
||||
if (base == null) {
|
||||
BCLib.LOGGER.warning("Base input for Smithing recipe can't be 'null', recipe {} will be ignored!", id);
|
||||
return;
|
||||
}
|
||||
if (addition == null) {
|
||||
BCLib.LOGGER.warning("Addition input for Smithing recipe can't be 'null', recipe {} will be ignored!", id);
|
||||
return;
|
||||
}
|
||||
if(result == null) {
|
||||
BCLib.LOGGER.warning("Result for Smithing recipe can't be 'null', recipe {} will be ignored!", id);
|
||||
return;
|
||||
}
|
||||
if (BCLRecipeManager.getRecipe(TYPE, id) != null) {
|
||||
BCLib.LOGGER.warning("Can't add Smithing recipe! Id {} already exists!", id);
|
||||
return;
|
||||
}
|
||||
if (!alright) {
|
||||
BCLib.LOGGER.debug("Can't add Smithing recipe {}! Ingeredients or output not exists.", id);
|
||||
return;
|
||||
}
|
||||
BCLRecipeManager.addRecipe(TYPE, new UpgradeRecipe(id, base, addition, result));
|
||||
}
|
||||
}
|
|
@ -4,6 +4,10 @@
|
|||
"package": "ru.bclib.mixin.common",
|
||||
"compatibilityLevel": "JAVA_8",
|
||||
"mixins": [
|
||||
"ComposterBlockAccessor",
|
||||
"PotionBrewingAccessor",
|
||||
"RecipeManagerAccessor",
|
||||
"RecipeManagerMixin",
|
||||
"BoneMealItemMixin",
|
||||
"TagLoaderMixin"
|
||||
],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue