Adds option to import from Open5e.com

This commit is contained in:
2026-09-21 12:08:25 -07:00
committed by Tom
parent d099d878a0
commit 338a7e4c0e
8 changed files with 262 additions and 21 deletions

View File

@@ -19,6 +19,7 @@ android {
defaultConfig {
applicationId "com.majinnaibu.monstercards"
minSdkVersion 24
//noinspection OldTargetApi
targetSdk 36
versionCode 1
versionName "1.0"

View File

@@ -21,16 +21,20 @@ import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.snackbar.Snackbar;
import com.google.gson.Gson;
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.importers.BinderImporter;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.importers.Open5eApiWrapper;
import com.majinnaibu.monstercards.init.AppCenterInitializer;
import com.majinnaibu.monstercards.models.BinderExport;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.utils.Logger;
import com.majinnaibu.monstercards.utils.SnackbarHelper;
import com.majinnaibu.monstercards.utils.ToastHelper;
import java.io.BufferedReader;
@@ -44,10 +48,13 @@ import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MainActivity extends AppCompatActivity {
private Disposable mOpen5eImportDisposable;
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
@@ -67,7 +74,7 @@ public class MainActivity extends AppCompatActivity {
AppCenterInitializer.init(getApplication());
setContentView(R.layout.activity_main);
com.google.android.material.appbar.MaterialToolbar toolbar = findViewById(R.id.toolbar);
MaterialToolbar toolbar = findViewById(R.id.toolbar);
if (toolbar != null) {
setSupportActionBar(toolbar);
}
@@ -202,6 +209,55 @@ public class MainActivity extends AppCompatActivity {
}
}
public void importAllFromOpen5e() {
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) {
ToastHelper.showShort(this, "Open5e import already running");
return;
}
View rootView = findViewById(android.R.id.content);
Snackbar snackbar = SnackbarHelper.makeIndefinite(rootView, R.string.snackbar_importing_open5e);
snackbar.setAction(R.string.action_cancel, v -> {
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) {
mOpen5eImportDisposable.dispose();
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_cancelled);
}
});
snackbar.show();
mOpen5eImportDisposable = Single.fromCallable(() -> {
int totalImported = 0;
String nextUrl = null;
do {
if (mOpen5eImportDisposable != null && mOpen5eImportDisposable.isDisposed()) {
break;
}
Open5eApiWrapper.Open5ePageResult page = Open5eApiWrapper.fetchPage(nextUrl);
for (Monster monster : page.monsters) {
if (mOpen5eImportDisposable != null && mOpen5eImportDisposable.isDisposed()) {
break;
}
((MonsterCardsApplication) getApplication()).getMonsterRepository()
.saveMonster(monster)
.blockingAwait();
totalImported++;
}
nextUrl = page.nextUrl;
} while (nextUrl != null && !nextUrl.isEmpty());
return totalImported;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
snackbar.dismiss();
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_complete);
}, throwable -> {
snackbar.dismiss();
Logger.logError("Failed to import all Open5e monsters", throwable);
SnackbarHelper.showLong(rootView, R.string.snackbar_open5e_import_failed);
});
}
public void importMultipleFilesFromUris(@NonNull List<Uri> uris) {
if (uris.isEmpty()) return;
if (uris.size() == 1) {
@@ -370,4 +426,12 @@ public class MainActivity extends AppCompatActivity {
}
return builder.toString();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mOpen5eImportDisposable != null && !mOpen5eImportDisposable.isDisposed()) {
mOpen5eImportDisposable.dispose();
}
}
}

View File

@@ -0,0 +1,85 @@
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.models.Monster;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import com.majinnaibu.monstercards.utils.Logger;
public class Open5eApiWrapper {
private static final String BASE_URL = "https://api.open5e.com/v2/creatures/";
public static class Open5ePageResult {
public List<Monster> monsters = new ArrayList<>();
@Nullable public String nextUrl;
}
@NonNull
public static Open5ePageResult fetchPage(@Nullable String urlStr) throws Exception {
if (urlStr == null || urlStr.isEmpty()) {
urlStr = BASE_URL;
}
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile)");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
int code = conn.getResponseCode();
if (code != 200) {
throw new IllegalStateException("Open5e API 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);
}
}
String jsonPayload = sb.toString();
JsonObject root = JsonParser.parseString(jsonPayload).getAsJsonObject();
Open5ePageResult result = new Open5ePageResult();
if (root.has("next") && !root.get("next").isJsonNull()) {
result.nextUrl = root.get("next").getAsString();
}
if (root.has("results") && root.get("results").isJsonArray()) {
JsonArray results = root.getAsJsonArray("results");
Open5eImporter importer = new Open5eImporter();
for (int i = 0; i < results.size(); i++) {
JsonElement el = results.get(i);
if (el.isJsonObject()) {
try {
Monster monster = importer.parse(el.toString());
result.monsters.add(monster);
} catch (Exception e) {
Logger.logError("Failed to parse Open5e monster", e);
}
}
}
}
return result;
}
}

View File

@@ -82,7 +82,16 @@ public class Open5eImporter implements EntityImporter<Monster> {
parseArmorClass(props, monster);
parseHitPoints(props, monster);
monster.walkSpeed = getInt(props, "speed", 30);
if (props.has("speed") && props.get("speed").isJsonObject()) {
JsonObject speedObj = props.getAsJsonObject("speed");
monster.walkSpeed = getInt(speedObj, "walk", 30);
monster.flySpeed = getInt(speedObj, "fly", 0);
monster.swimSpeed = getInt(speedObj, "swim", 0);
monster.burrowSpeed = getInt(speedObj, "burrow", 0);
monster.climbSpeed = getInt(speedObj, "climb", 0);
} else {
monster.walkSpeed = getInt(props, "speed", 30);
}
if (props.has("speed_desc")) {
String speedDesc = getString(props, "speed_desc");
if (!speedDesc.isEmpty()) {
@@ -159,6 +168,10 @@ public class Open5eImporter implements EntityImporter<Monster> {
crStr = props.get("challenge_rating").getAsString();
}
if (!crStr.isEmpty()) {
crStr = crStr.replace(".0", "");
if (crStr.equals("0.125")) crStr = "1/8";
else if (crStr.equals("0.25")) crStr = "1/4";
else if (crStr.equals("0.5")) crStr = "1/2";
monster.challengeRating = ChallengeRating.valueOfString(crStr);
}
}
@@ -214,7 +227,12 @@ public class Open5eImporter implements EntityImporter<Monster> {
private void parseSavingThrows(JsonObject props, Monster monster) {
if (!props.has("saving_throws")) return;
JsonElement savesEl = props.get("saving_throws");
if (savesEl.isJsonArray()) {
if (savesEl.isJsonObject()) {
JsonObject savesObj = savesEl.getAsJsonObject();
for (String key : savesObj.keySet()) {
applySaveProficiency(monster, key.toLowerCase(Locale.ROOT));
}
} else if (savesEl.isJsonArray()) {
JsonArray arr = savesEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String saveStr = arr.get(i).getAsString().toLowerCase(Locale.ROOT);
@@ -233,16 +251,23 @@ public class Open5eImporter implements EntityImporter<Monster> {
}
private void parseSkills(JsonObject props, Monster monster) {
if (!props.has("skills")) return;
JsonElement skillsEl = props.get("skills");
if (skillsEl.isJsonArray()) {
JsonArray arr = skillsEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String skillStr = arr.get(i).getAsString();
String skillName = skillStr.replaceAll("[+-]\\d+", "").trim();
if (!skillName.isEmpty()) {
AbilityScore stat = getAbilityForSkill(skillName);
monster.skills.add(new Skill(capitalize(skillName), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT));
if (props.has("skill_bonuses") && props.get("skill_bonuses").isJsonObject()) {
JsonObject skillsObj = props.getAsJsonObject("skill_bonuses");
for (String key : skillsObj.keySet()) {
AbilityScore stat = getAbilityForSkill(key);
monster.skills.add(new Skill(capitalize(key.replace("_", " ")), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT));
}
} else if (props.has("skills")) {
JsonElement skillsEl = props.get("skills");
if (skillsEl.isJsonArray()) {
JsonArray arr = skillsEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String skillStr = arr.get(i).getAsString();
String skillName = skillStr.replaceAll("[+-]\\d+", "").trim();
if (!skillName.isEmpty()) {
AbilityScore stat = getAbilityForSkill(skillName);
monster.skills.add(new Skill(capitalize(skillName), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT));
}
}
}
}
@@ -274,7 +299,26 @@ public class Open5eImporter implements EntityImporter<Monster> {
private void parseLanguages(JsonObject props, Monster monster) {
if (props.has("languages")) {
JsonElement langEl = props.get("languages");
if (langEl.isJsonArray()) {
if (langEl.isJsonObject()) {
if (langEl.getAsJsonObject().has("data") && langEl.getAsJsonObject().get("data").isJsonArray()) {
JsonArray arr = langEl.getAsJsonObject().getAsJsonArray("data");
for (int i = 0; i < arr.size(); i++) {
if (arr.get(i).isJsonObject()) {
String langName = getString(arr.get(i).getAsJsonObject(), "name");
if (!langName.isEmpty()) {
monster.languages.add(new Language(langName, true));
}
}
}
} else if (langEl.getAsJsonObject().has("as_string")) {
String langStr = langEl.getAsJsonObject().get("as_string").getAsString();
for (String l : langStr.split(",")) {
if (!l.trim().isEmpty()) {
monster.languages.add(new Language(l.trim(), true));
}
}
}
} else if (langEl.isJsonArray()) {
JsonArray arr = langEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String langName = arr.get(i).getAsString();
@@ -294,10 +338,18 @@ public class Open5eImporter implements EntityImporter<Monster> {
}
private void parseDamageAndConditions(JsonObject props, Monster monster) {
parseStringSet(props, "damage_immunities", monster.damageImmunities);
parseStringSet(props, "damage_resistances", monster.damageResistances);
parseStringSet(props, "damage_vulnerabilities", monster.damageVulnerabilities);
parseStringSet(props, "condition_immunities", monster.conditionImmunities);
if (props.has("resistances_and_immunities") && props.get("resistances_and_immunities").isJsonObject()) {
JsonObject resObj = props.getAsJsonObject("resistances_and_immunities");
parseStringSet(resObj, "damage_immunities", monster.damageImmunities);
parseStringSet(resObj, "damage_resistances", monster.damageResistances);
parseStringSet(resObj, "damage_vulnerabilities", monster.damageVulnerabilities);
parseStringSet(resObj, "condition_immunities", monster.conditionImmunities);
} else {
parseStringSet(props, "damage_immunities", monster.damageImmunities);
parseStringSet(props, "damage_resistances", monster.damageResistances);
parseStringSet(props, "damage_vulnerabilities", monster.damageVulnerabilities);
parseStringSet(props, "condition_immunities", monster.conditionImmunities);
}
}
private void parseStringSet(JsonObject props, String key, java.util.Set<String> set) {
@@ -306,8 +358,14 @@ public class Open5eImporter implements EntityImporter<Monster> {
if (el.isJsonArray()) {
JsonArray arr = el.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String val = arr.get(i).getAsString();
if (!val.isEmpty()) set.add(capitalize(val));
JsonElement item = arr.get(i);
if (item.isJsonObject() && item.getAsJsonObject().has("name")) {
String val = item.getAsJsonObject().get("name").getAsString();
if (!val.isEmpty()) set.add(capitalize(val));
} else if (item.isJsonPrimitive()) {
String val = item.getAsString();
if (!val.isEmpty()) set.add(capitalize(val));
}
}
} else if (el.isJsonPrimitive()) {
for (String val : el.getAsString().split(",")) {
@@ -357,7 +415,12 @@ public class Open5eImporter implements EntityImporter<Monster> {
private static String getString(JsonObject obj, String key, String defaultVal) {
if (obj.has(key) && !obj.get(key).isJsonNull()) {
return obj.get(key).getAsString();
JsonElement el = obj.get(key);
if (el.isJsonPrimitive()) {
return el.getAsString();
} else if (el.isJsonObject() && el.getAsJsonObject().has("name")) {
return el.getAsJsonObject().get("name").getAsString();
}
}
return defaultVal;
}

View File

@@ -155,6 +155,13 @@ public class MCFragment extends Fragment {
dialog.dismiss();
});
view.findViewById(R.id.button_import_open5e).setOnClickListener(v -> {
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importAllFromOpen5e();
}
dialog.dismiss();
});
dialog.show();
}

View File

@@ -31,6 +31,14 @@ public class SnackbarHelper {
return make(view, message, DEFAULT_LONG_DURATION);
}
public static Snackbar makeIndefinite(@NonNull View view, @StringRes int resId) {
return Snackbar.make(view, resId, Snackbar.LENGTH_INDEFINITE);
}
public static Snackbar makeIndefinite(@NonNull View view, @NonNull CharSequence message) {
return Snackbar.make(view, message, Snackbar.LENGTH_INDEFINITE);
}
public static Snackbar make(@NonNull View view, @NonNull CharSequence message, int durationMs) {
return Snackbar.make(view, message, durationMs);
}

View File

@@ -40,4 +40,11 @@
android:layout_height="wrap_content"
android:text="@string/action_import_monster_from_file" />
<Button
android:id="@+id/button_import_open5e"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/action_import_from_open5e" />
</LinearLayout>

View File

@@ -163,6 +163,7 @@
<string name="snackbar_dashboard_cleared">Dashboard cleared</string>
<string name="no_monsters_available">No monsters available in library. Create one first!</string>
<string name="action_import_from_url">Import URL</string>
<string name="action_import_from_open5e">From Open5e.com</string>
<string name="dialog_import_title">Import</string>
<string name="dialog_import_message">Enter or paste a URL, ID, or data payload:</string>
<string name="dialog_import_url_hint">e.g. D&amp;D Beyond URL or Open5e JSON</string>
@@ -225,4 +226,9 @@
<string name="dialog_remove_all_from_collection_title">Remove All Monsters?</string>
<string name="dialog_remove_all_from_collection_message">Are you sure you want to remove all monsters from this collection?</string>
<string name="snackbar_collection_cleared">Removed all monsters from collection</string>
<string name="snackbar_importing_open5e">Importing monsters from Open5e…</string>
<string name="action_cancel">Cancel</string>
<string name="snackbar_open5e_import_complete">Finished importing Open5e monsters.</string>
<string name="snackbar_open5e_import_cancelled">Cancelled Open5e import.</string>
<string name="snackbar_open5e_import_failed">Failed to import from Open5e.</string>
</resources>