Adds ability to import from a public dndbeyond character.
This commit is contained in:
@@ -16,8 +16,13 @@ import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.navigation.ui.AppBarConfiguration;
|
||||
import androidx.navigation.ui.NavigationUI;
|
||||
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
import com.google.gson.Gson;
|
||||
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
|
||||
import com.majinnaibu.monstercards.helpers.StringHelper;
|
||||
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
|
||||
import com.majinnaibu.monstercards.init.AppCenterInitializer;
|
||||
import com.majinnaibu.monstercards.utils.Logger;
|
||||
|
||||
@@ -28,6 +33,10 @@ import java.io.InputStreamReader;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
@@ -66,15 +75,57 @@ public class MainActivity extends AppCompatActivity {
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
Bundle extras = intent.getExtras();
|
||||
|
||||
if ("android.intent.action.SEND".equals(action) && extras != null) {
|
||||
CharSequence sharedText = extras.getCharSequence(Intent.EXTRA_TEXT);
|
||||
if (sharedText != null) {
|
||||
String textStr = sharedText.toString().trim();
|
||||
DnDBeyondImporter dndImporter = new DnDBeyondImporter();
|
||||
if (dndImporter.canImport(textStr)) {
|
||||
importMonsterFromInputAndNavigate(textStr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if ("android.intent.action.VIEW".equals(action)) {
|
||||
Uri dataUri = intent.getData();
|
||||
if (dataUri != null) {
|
||||
String dataStr = dataUri.toString().trim();
|
||||
DnDBeyondImporter dndImporter = new DnDBeyondImporter();
|
||||
if (dndImporter.canImport(dataStr)) {
|
||||
importMonsterFromInputAndNavigate(dataStr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String json = readMonsterJSONFromIntent(intent);
|
||||
if (!StringHelper.isNullOrEmpty(json)) {
|
||||
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
|
||||
NavController navController = navHostFragment.getNavController();
|
||||
NavDirections action = MobileNavigationDirections.actionGlobalMonsterImportFragment(json);
|
||||
navController.navigate(action);
|
||||
importMonsterFromInputAndNavigate(json);
|
||||
}
|
||||
}
|
||||
|
||||
public void importMonsterFromInputAndNavigate(@NonNull String input) {
|
||||
Toast.makeText(this, R.string.toast_importing_url, Toast.LENGTH_SHORT).show();
|
||||
Single.fromCallable(() -> MonsterImportHelper.fromJSON(input))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(monster -> {
|
||||
String serializedJson = new Gson().toJson(monster);
|
||||
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
|
||||
NavController navController = navHostFragment.getNavController();
|
||||
NavDirections navAction = MobileNavigationDirections.actionGlobalMonsterImportFragment(serializedJson);
|
||||
navController.navigate(navAction);
|
||||
}, throwable -> {
|
||||
Logger.logError("Failed to import monster from input", throwable);
|
||||
Toast.makeText(this, R.string.failed_to_import_url, Toast.LENGTH_LONG).show();
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String readMonsterJSONFromIntent(@NonNull Intent intent) {
|
||||
String action = intent.getAction();
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.majinnaibu.monstercards.helpers;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
|
||||
import com.majinnaibu.monstercards.importers.EntityImporter;
|
||||
import com.majinnaibu.monstercards.importers.MonsterJsonImporter;
|
||||
import com.majinnaibu.monstercards.importers.TetraCubeMonsterImporter;
|
||||
import com.majinnaibu.monstercards.models.Monster;
|
||||
|
||||
@@ -13,7 +15,9 @@ public class MonsterImportHelper {
|
||||
private static final List<EntityImporter<Monster>> IMPORTERS = new ArrayList<>();
|
||||
|
||||
static {
|
||||
IMPORTERS.add(new MonsterJsonImporter());
|
||||
IMPORTERS.add(new TetraCubeMonsterImporter());
|
||||
IMPORTERS.add(new DnDBeyondImporter());
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
package com.majinnaibu.monstercards.importers;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.majinnaibu.monstercards.data.enums.AbilityScore;
|
||||
import com.majinnaibu.monstercards.data.enums.AdvantageType;
|
||||
import com.majinnaibu.monstercards.data.enums.ProficiencyType;
|
||||
import com.majinnaibu.monstercards.models.Language;
|
||||
import com.majinnaibu.monstercards.models.Monster;
|
||||
import com.majinnaibu.monstercards.models.Skill;
|
||||
import com.majinnaibu.monstercards.models.Trait;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
public class DnDBeyondImporter implements EntityImporter<Monster> {
|
||||
|
||||
private static final Pattern DNDBEYOND_URL_PATTERN = Pattern.compile("https?://(?:www\\.)?dndbeyond\\.com/characters/(\\d+)(?:/([a-zA-Z0-9]+))?", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CHARACTER_ID_PATTERN = Pattern.compile("^\\d+$");
|
||||
|
||||
@Override
|
||||
public boolean canImport(@NonNull String input) {
|
||||
String trimmed = input.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (DNDBEYOND_URL_PATTERN.matcher(trimmed).find() || CHARACTER_ID_PATTERN.matcher(trimmed).matches()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JsonElement element = JsonParser.parseString(trimmed);
|
||||
if (element.isJsonObject()) {
|
||||
JsonObject root = element.getAsJsonObject();
|
||||
if (root.has("data") && root.getAsJsonObject("data").has("baseHitPoints")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Monster parse(@NonNull String input) throws Exception {
|
||||
String trimmed = input.trim();
|
||||
JsonObject characterData;
|
||||
String sourceUrl = "";
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
JsonObject root = JsonParser.parseString(trimmed).getAsJsonObject();
|
||||
characterData = root.has("data") ? root.getAsJsonObject("data") : root;
|
||||
} else {
|
||||
String characterId = extractCharacterId(trimmed);
|
||||
if (characterId == null) {
|
||||
throw new IllegalArgumentException("Could not extract D&D Beyond character ID from input: " + input);
|
||||
}
|
||||
sourceUrl = trimmed.startsWith("http") ? trimmed : "https://www.dndbeyond.com/characters/" + characterId;
|
||||
String jsonPayload = fetchCharacterJson(characterId);
|
||||
JsonObject root = JsonParser.parseString(jsonPayload).getAsJsonObject();
|
||||
if (!root.has("data") || root.get("data").isJsonNull()) {
|
||||
throw new IllegalArgumentException("D&D Beyond response did not contain character data.");
|
||||
}
|
||||
characterData = root.getAsJsonObject("data");
|
||||
}
|
||||
|
||||
return buildMonsterFromCharacterData(characterData, sourceUrl);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String extractCharacterId(@NonNull String input) {
|
||||
String trimmed = input.trim();
|
||||
Matcher urlMatcher = DNDBEYOND_URL_PATTERN.matcher(trimmed);
|
||||
if (urlMatcher.find()) {
|
||||
return urlMatcher.group(1);
|
||||
}
|
||||
if (CHARACTER_ID_PATTERN.matcher(trimmed).matches()) {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private String fetchCharacterJson(@NonNull String characterId) throws Exception {
|
||||
String serviceUrl = "https://character-service.dndbeyond.com/character/v5/character/" + characterId + "?includeCustomItems=true";
|
||||
URL url = new URL(serviceUrl);
|
||||
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile)");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code != 200) {
|
||||
throw new IllegalStateException("D&D Beyond service returned HTTP " + code);
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (InputStream in = conn.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Monster buildMonsterFromCharacterData(@NonNull JsonObject data, @NonNull String sourceUrl) {
|
||||
Monster monster = new Monster();
|
||||
monster.sourceUrl = sourceUrl;
|
||||
|
||||
String rawName = getString(data, "name");
|
||||
if (rawName.startsWith("\"") && rawName.endsWith("\"") && rawName.length() > 1) {
|
||||
rawName = rawName.substring(1, rawName.length() - 1);
|
||||
}
|
||||
monster.name = rawName.isEmpty() ? "D&D Beyond Character" : rawName;
|
||||
|
||||
monster.size = "Medium";
|
||||
monster.type = "Humanoid";
|
||||
|
||||
parseClassesAndLevel(data, monster);
|
||||
parseStats(data, monster);
|
||||
parseHitPoints(data, monster);
|
||||
parseSpeedAndSenses(data, monster);
|
||||
parseModifiers(data, monster);
|
||||
parseTraitsAndFeatures(data, monster);
|
||||
parseActionsAndAttacks(data, monster);
|
||||
|
||||
return monster;
|
||||
}
|
||||
|
||||
private void parseClassesAndLevel(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
StringBuilder classSummary = new StringBuilder();
|
||||
int totalLevel = 0;
|
||||
if (data.has("classes") && data.get("classes").isJsonArray()) {
|
||||
JsonArray classes = data.getAsJsonArray("classes");
|
||||
for (int i = 0; i < classes.size(); i++) {
|
||||
JsonObject cls = classes.get(i).getAsJsonObject();
|
||||
int level = getInt(cls, "level", 1);
|
||||
totalLevel += level;
|
||||
String className = "";
|
||||
if (cls.has("definition") && cls.get("definition").isJsonObject()) {
|
||||
className = getString(cls.getAsJsonObject("definition"), "name");
|
||||
}
|
||||
String subclassName = "";
|
||||
if (cls.has("subclassDefinition") && cls.get("subclassDefinition").isJsonObject()) {
|
||||
subclassName = getString(cls.getAsJsonObject("subclassDefinition"), "name");
|
||||
}
|
||||
if (classSummary.length() > 0) {
|
||||
classSummary.append(" / ");
|
||||
}
|
||||
classSummary.append(className);
|
||||
if (!subclassName.isEmpty()) {
|
||||
classSummary.append(" (").append(subclassName).append(")");
|
||||
}
|
||||
classSummary.append(" ").append(level);
|
||||
}
|
||||
}
|
||||
monster.hitDice = totalLevel > 0 ? totalLevel : 1;
|
||||
String raceName = "";
|
||||
if (data.has("race") && data.get("race").isJsonObject()) {
|
||||
JsonObject race = data.getAsJsonObject("race");
|
||||
raceName = getString(race, "fullName");
|
||||
if (raceName.isEmpty()) {
|
||||
raceName = getString(race, "baseRaceName");
|
||||
}
|
||||
}
|
||||
StringBuilder subtypeSb = new StringBuilder();
|
||||
if (totalLevel > 0) {
|
||||
subtypeSb.append("Level ").append(totalLevel);
|
||||
}
|
||||
if (!raceName.isEmpty()) {
|
||||
if (subtypeSb.length() > 0) subtypeSb.append(" ");
|
||||
subtypeSb.append(raceName);
|
||||
}
|
||||
if (classSummary.length() > 0) {
|
||||
if (subtypeSb.length() > 0) subtypeSb.append(" ");
|
||||
subtypeSb.append(classSummary);
|
||||
}
|
||||
monster.subtype = subtypeSb.toString();
|
||||
}
|
||||
|
||||
private void parseStats(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
if (data.has("stats") && data.get("stats").isJsonArray()) {
|
||||
JsonArray stats = data.getAsJsonArray("stats");
|
||||
for (int i = 0; i < stats.size(); i++) {
|
||||
JsonObject stat = stats.get(i).getAsJsonObject();
|
||||
int id = getInt(stat, "id");
|
||||
int val = getInt(stat, "value", 10);
|
||||
switch (id) {
|
||||
case 1: monster.strengthScore = val; break;
|
||||
case 2: monster.dexterityScore = val; break;
|
||||
case 3: monster.constitutionScore = val; break;
|
||||
case 4: monster.intelligenceScore = val; break;
|
||||
case 5: monster.wisdomScore = val; break;
|
||||
case 6: monster.charismaScore = val; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void parseHitPoints(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
int baseHp = getInt(data, "baseHitPoints");
|
||||
int overrideHp = getInt(data, "overrideHitPoints");
|
||||
int maxHp = overrideHp > 0 ? overrideHp : baseHp;
|
||||
if (maxHp > 0) {
|
||||
monster.hasCustomHP = true;
|
||||
monster.customHPDescription = String.format(Locale.ROOT, "%d (%dd%d)", maxHp, monster.hitDice, 8);
|
||||
}
|
||||
}
|
||||
|
||||
private void parseSpeedAndSenses(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
if (data.has("race") && data.get("race").isJsonObject()) {
|
||||
JsonObject race = data.getAsJsonObject("race");
|
||||
if (race.has("weightSpeeds") && race.get("weightSpeeds").isJsonObject()) {
|
||||
JsonObject weightSpeeds = race.getAsJsonObject("weightSpeeds");
|
||||
if (weightSpeeds.has("normal") && weightSpeeds.get("normal").isJsonObject()) {
|
||||
JsonObject normal = weightSpeeds.getAsJsonObject("normal");
|
||||
monster.walkSpeed = getInt(normal, "walk", 30);
|
||||
monster.flySpeed = getInt(normal, "fly", 0);
|
||||
monster.burrowSpeed = getInt(normal, "burrow", 0);
|
||||
monster.swimSpeed = getInt(normal, "swim", 0);
|
||||
monster.climbSpeed = getInt(normal, "climb", 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (monster.walkSpeed == 0) {
|
||||
monster.walkSpeed = 30;
|
||||
}
|
||||
}
|
||||
|
||||
private void parseModifiers(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
if (!data.has("modifiers") || !data.get("modifiers").isJsonObject()) {
|
||||
return;
|
||||
}
|
||||
JsonObject modifiersObj = data.getAsJsonObject("modifiers");
|
||||
String[] categories = new String[]{"race", "class", "background", "item", "feat"};
|
||||
|
||||
for (String cat : categories) {
|
||||
if (modifiersObj.has(cat) && modifiersObj.get(cat).isJsonArray()) {
|
||||
JsonArray mods = modifiersObj.getAsJsonArray(cat);
|
||||
for (int i = 0; i < mods.size(); i++) {
|
||||
JsonObject mod = mods.get(i).getAsJsonObject();
|
||||
String type = getString(mod, "type");
|
||||
String subType = getString(mod, "subType");
|
||||
int fixedVal = getInt(mod, "fixedValue");
|
||||
|
||||
if ("set-base".equals(type) && "darkvision".equals(subType)) {
|
||||
int dist = fixedVal > 0 ? fixedVal : 60;
|
||||
monster.senses.add("darkvision " + dist + " ft.");
|
||||
} else if ("language".equals(type)) {
|
||||
String langName = capitalize(subType.replace("-", " "));
|
||||
monster.languages.add(new Language(langName, true));
|
||||
} else if ("proficiency".equals(type)) {
|
||||
if (subType.endsWith("-saving-throws")) {
|
||||
String stat = subType.replace("-saving-throws", "");
|
||||
applySavingThrowProficiency(monster, stat);
|
||||
} else {
|
||||
applySkillProficiency(monster, subType, ProficiencyType.PROFICIENT);
|
||||
}
|
||||
} else if ("expertise".equals(type)) {
|
||||
applySkillProficiency(monster, subType, ProficiencyType.EXPERTISE);
|
||||
} else if ("immunity".equals(type)) {
|
||||
monster.damageImmunities.add(capitalize(subType.replace("-", " ")));
|
||||
} else if ("resistance".equals(type)) {
|
||||
monster.damageResistances.add(capitalize(subType.replace("-", " ")));
|
||||
} else if ("vulnerability".equals(type)) {
|
||||
monster.damageVulnerabilities.add(capitalize(subType.replace("-", " ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applySavingThrowProficiency(@NonNull Monster monster, String stat) {
|
||||
if ("strength".equals(stat) || "str".equals(stat)) monster.strengthSavingThrowProficiency = ProficiencyType.PROFICIENT;
|
||||
else if ("dexterity".equals(stat) || "dex".equals(stat)) monster.dexteritySavingThrowProficiency = ProficiencyType.PROFICIENT;
|
||||
else if ("constitution".equals(stat) || "con".equals(stat)) monster.constitutionSavingThrowProficiency = ProficiencyType.PROFICIENT;
|
||||
else if ("intelligence".equals(stat) || "int".equals(stat)) monster.intelligenceSavingThrowProficiency = ProficiencyType.PROFICIENT;
|
||||
else if ("wisdom".equals(stat) || "wis".equals(stat)) monster.wisdomSavingThrowProficiency = ProficiencyType.PROFICIENT;
|
||||
else if ("charisma".equals(stat) || "cha".equals(stat)) monster.charismaSavingThrowProficiency = ProficiencyType.PROFICIENT;
|
||||
}
|
||||
|
||||
private void applySkillProficiency(@NonNull Monster monster, String subType, ProficiencyType profType) {
|
||||
String skillName = capitalize(subType.replace("-", " "));
|
||||
AbilityScore abilityScore = AbilityScore.STRENGTH;
|
||||
if ("Acrobatics".equalsIgnoreCase(skillName) || "Sleight Of Hand".equalsIgnoreCase(skillName) || "Stealth".equalsIgnoreCase(skillName)) {
|
||||
abilityScore = AbilityScore.DEXTERITY;
|
||||
} else if ("Arcana".equalsIgnoreCase(skillName) || "History".equalsIgnoreCase(skillName) || "Investigation".equalsIgnoreCase(skillName) || "Nature".equalsIgnoreCase(skillName) || "Religion".equalsIgnoreCase(skillName)) {
|
||||
abilityScore = AbilityScore.INTELLIGENCE;
|
||||
} else if ("Animal Handling".equalsIgnoreCase(skillName) || "Insight".equalsIgnoreCase(skillName) || "Medicine".equalsIgnoreCase(skillName) || "Perception".equalsIgnoreCase(skillName) || "Survival".equalsIgnoreCase(skillName)) {
|
||||
abilityScore = AbilityScore.WISDOM;
|
||||
} else if ("Deception".equalsIgnoreCase(skillName) || "Intimidation".equalsIgnoreCase(skillName) || "Performance".equalsIgnoreCase(skillName) || "Persuasion".equalsIgnoreCase(skillName)) {
|
||||
abilityScore = AbilityScore.CHARISMA;
|
||||
} else if ("Athletics".equalsIgnoreCase(skillName)) {
|
||||
abilityScore = AbilityScore.STRENGTH;
|
||||
}
|
||||
monster.skills.add(new Skill(skillName, abilityScore, AdvantageType.NONE, profType));
|
||||
}
|
||||
|
||||
private void parseTraitsAndFeatures(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
if (data.has("race") && data.get("race").isJsonObject()) {
|
||||
JsonObject race = data.getAsJsonObject("race");
|
||||
if (race.has("racialTraits") && race.get("racialTraits").isJsonArray()) {
|
||||
JsonArray traits = race.getAsJsonArray("racialTraits");
|
||||
for (int i = 0; i < traits.size(); i++) {
|
||||
JsonObject tr = traits.get(i).getAsJsonObject();
|
||||
if (tr.has("definition") && tr.get("definition").isJsonObject()) {
|
||||
JsonObject def = tr.getAsJsonObject("definition");
|
||||
String name = getString(def, "name");
|
||||
String desc = stripHtml(getString(def, "snippet"));
|
||||
if (desc.isEmpty()) {
|
||||
desc = stripHtml(getString(def, "description"));
|
||||
}
|
||||
if (!name.isEmpty() && !desc.isEmpty()) {
|
||||
monster.abilities.add(new Trait(name, desc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void parseActionsAndAttacks(@NonNull JsonObject data, @NonNull Monster monster) {
|
||||
if (data.has("actions") && data.get("actions").isJsonObject()) {
|
||||
JsonObject actionsObj = data.getAsJsonObject("actions");
|
||||
String[] categories = new String[]{"class", "race", "background", "item", "feat"};
|
||||
for (String cat : categories) {
|
||||
if (actionsObj.has(cat) && actionsObj.get(cat).isJsonArray()) {
|
||||
JsonArray acts = actionsObj.getAsJsonArray(cat);
|
||||
for (int i = 0; i < acts.size(); i++) {
|
||||
JsonObject act = acts.get(i).getAsJsonObject();
|
||||
String name = getString(act, "name");
|
||||
String desc = stripHtml(getString(act, "snippet"));
|
||||
if (desc.isEmpty()) {
|
||||
desc = stripHtml(getString(act, "description"));
|
||||
}
|
||||
int actType = 1;
|
||||
if (act.has("activation") && act.get("activation").isJsonObject()) {
|
||||
actType = getInt(act.getAsJsonObject("activation"), "activationType", 1);
|
||||
}
|
||||
if (!name.isEmpty()) {
|
||||
Trait trait = new Trait(name, desc);
|
||||
if (actType == 3) {
|
||||
monster.bonusActions.add(trait);
|
||||
} else if (actType == 4) {
|
||||
monster.reactions.add(trait);
|
||||
} else {
|
||||
monster.actions.add(trait);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String getString(@NonNull JsonObject obj, String key) {
|
||||
return getString(obj, key, "");
|
||||
}
|
||||
|
||||
private static String getString(@NonNull JsonObject obj, String key, String defaultVal) {
|
||||
if (obj.has(key) && !obj.get(key).isJsonNull()) {
|
||||
return obj.get(key).getAsString();
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
private static int getInt(@NonNull JsonObject obj, String key) {
|
||||
return getInt(obj, key, 0);
|
||||
}
|
||||
|
||||
private static int getInt(@NonNull JsonObject obj, String key, int defaultVal) {
|
||||
if (obj.has(key) && !obj.get(key).isJsonNull()) {
|
||||
try {
|
||||
return obj.get(key).getAsInt();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
private static String stripHtml(String html) {
|
||||
if (html == null) return "";
|
||||
return html.replaceAll("<[^>]*>", "").replaceAll(" ", " ").trim();
|
||||
}
|
||||
|
||||
private static String capitalize(String str) {
|
||||
if (str == null || str.isEmpty()) return str;
|
||||
String[] words = str.split("\\s+");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String w : words) {
|
||||
if (!w.isEmpty()) {
|
||||
if (sb.length() > 0) sb.append(" ");
|
||||
sb.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1).toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.majinnaibu.monstercards.importers;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.majinnaibu.monstercards.models.Monster;
|
||||
|
||||
public class MonsterJsonImporter implements EntityImporter<Monster> {
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
@Override
|
||||
public boolean canImport(@NonNull String input) {
|
||||
try {
|
||||
JsonElement el = JsonParser.parseString(input);
|
||||
if (el.isJsonObject()) {
|
||||
JsonObject obj = el.getAsJsonObject();
|
||||
return obj.has("name") && (obj.has("strengthScore") || obj.has("hitDice") || obj.has("size"));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Monster parse(@NonNull String input) throws Exception {
|
||||
Monster monster = GSON.fromJson(input, Monster.class);
|
||||
if (monster == null) {
|
||||
throw new IllegalArgumentException("Failed to deserialize Monster JSON");
|
||||
}
|
||||
return monster;
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,8 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
|
||||
return false;
|
||||
}
|
||||
JsonObject rootDict = element.getAsJsonObject();
|
||||
return rootDict.has("hitDice") || rootDict.has("armorName") || rootDict.has("strPoints")
|
||||
|| rootDict.has("bonusActions") || rootDict.has("isLegendary") || rootDict.has("isMythic")
|
||||
return rootDict.has("armorName") || rootDict.has("strPoints")
|
||||
|| rootDict.has("isLegendary") || rootDict.has("isMythic")
|
||||
|| rootDict.has("legendariesDescription");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package com.majinnaibu.monstercards.ui.library;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.NavDirections;
|
||||
import androidx.navigation.Navigation;
|
||||
@@ -17,8 +24,10 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.majinnaibu.monstercards.MainActivity;
|
||||
import com.majinnaibu.monstercards.R;
|
||||
import com.majinnaibu.monstercards.data.MonsterRepository;
|
||||
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
|
||||
import com.majinnaibu.monstercards.models.Monster;
|
||||
import com.majinnaibu.monstercards.ui.monster.MonsterDetailFragmentDirections;
|
||||
import com.majinnaibu.monstercards.ui.shared.MCFragment;
|
||||
@@ -35,6 +44,7 @@ public class LibraryFragment extends MCFragment {
|
||||
|
||||
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||
ViewGroup container, Bundle savedInstanceState) {
|
||||
setHasOptionsMenu(true);
|
||||
View root = inflater.inflate(R.layout.fragment_library, container, false);
|
||||
|
||||
FloatingActionButton fab = root.findViewById(R.id.fab);
|
||||
@@ -48,6 +58,59 @@ public class LibraryFragment extends MCFragment {
|
||||
return root;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.library_menu, menu);
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.menu_action_import_from_url) {
|
||||
showImportUrlDialog();
|
||||
return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
private void showImportUrlDialog() {
|
||||
Context context = requireContext();
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
builder.setTitle(R.string.dialog_import_url_title);
|
||||
builder.setMessage(R.string.dialog_import_url_message);
|
||||
|
||||
final EditText input = new EditText(context);
|
||||
input.setHint(R.string.dialog_import_url_hint);
|
||||
input.setSingleLine(true);
|
||||
|
||||
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard != null && clipboard.hasPrimaryClip()) {
|
||||
ClipData clip = clipboard.getPrimaryClip();
|
||||
if (clip != null && clip.getItemCount() > 0) {
|
||||
CharSequence clipText = clip.getItemAt(0).getText();
|
||||
if (clipText != null && new DnDBeyondImporter().canImport(clipText.toString())) {
|
||||
input.setText(clipText.toString().trim());
|
||||
input.selectAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.setView(input);
|
||||
|
||||
builder.setPositiveButton(R.string.dialog_import, (dialog, which) -> {
|
||||
String urlOrId = input.getText().toString().trim();
|
||||
if (!urlOrId.isEmpty()) {
|
||||
if (getActivity() instanceof MainActivity) {
|
||||
((MainActivity) getActivity()).importMonsterFromInputAndNavigate(urlOrId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
builder.setNegativeButton(R.string.dialog_cancel, (dialog, which) -> dialog.cancel());
|
||||
|
||||
builder.show();
|
||||
}
|
||||
|
||||
private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
|
||||
Context context = requireContext();
|
||||
MonsterRepository repository = this.getMonsterRepository();
|
||||
|
||||
Reference in New Issue
Block a user