Cleans up import and export options.

This commit is contained in:
2026-09-20 19:10:43 -07:00
parent 77176a6d4f
commit a3998d6c1d
31 changed files with 2280 additions and 110 deletions

View File

@@ -28,6 +28,8 @@ import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.importers.BinderImporter;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
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.ToastHelper;
@@ -35,6 +37,8 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -131,46 +135,161 @@ public class MainActivity extends AppCompatActivity {
}
}
Uri intentUri = getUriFromIntent(intent);
String fileName = intentUri != null ? getFileNameFromUri(intentUri) : null;
String json = readMonsterJSONFromIntent(intent);
if (!StringHelper.isNullOrEmpty(json)) {
importMonsterFromInputAndNavigate(json);
importMonsterFromInputAndNavigate(json, fileName);
}
}
@Nullable
private Uri getUriFromIntent(@NonNull Intent intent) {
String action = intent.getAction();
Bundle extras = intent.getExtras();
if ("android.intent.action.SEND".equals(action)) {
if (extras != null) {
return extras.getParcelable(Intent.EXTRA_STREAM);
}
} else if ("android.intent.action.VIEW".equals(action) || "android.intent.action.EDIT".equals(action)) {
return intent.getData();
}
return null;
}
public void importMonsterFromInputAndNavigate(@NonNull String input) {
BinderImporter binderImporter = new BinderImporter();
if (binderImporter.canImport(input)) {
importMonsterFromInputAndNavigate(input, null);
}
public void importMonsterFromInputAndNavigate(@NonNull String input, @Nullable String fileName) {
try {
BinderImporter binderImporter = new BinderImporter();
if (binderImporter.canImport(input)) {
ToastHelper.showShort(this, R.string.toast_importing_url);
Single.fromCallable(() -> binderImporter.parse(input))
.flatMapCompletable(binder -> ((MonsterCardsApplication) getApplication()).getMonsterRepository().importBinder(binder))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
ToastHelper.showLong(this, R.string.snackbar_import_binder_success);
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
NavController navController = navHostFragment.getNavController();
navController.navigate(R.id.navigation_library);
}, throwable -> {
Logger.logError("Failed to import binder from input: " + fileName, throwable);
ToastHelper.showLong(this, R.string.failed_to_import_url);
});
return;
}
ToastHelper.showShort(this, R.string.toast_importing_url);
Single.fromCallable(() -> binderImporter.parse(input))
.flatMapCompletable(binder -> ((MonsterCardsApplication) getApplication()).getMonsterRepository().importBinder(binder))
Single.fromCallable(() -> MonsterImportHelper.fromJSON(input, fileName))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
ToastHelper.showLong(this, R.string.snackbar_import_binder_success);
.subscribe(monster -> {
String serializedJson = new Gson().toJson(monster);
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
NavController navController = navHostFragment.getNavController();
navController.navigate(R.id.navigation_library);
NavDirections navAction = MobileNavigationDirections.actionGlobalMonsterImportFragment(serializedJson);
navController.navigate(navAction);
}, throwable -> {
Logger.logError("Failed to import binder from input", throwable);
Logger.logError("Failed to import monster from input: " + fileName, throwable);
ToastHelper.showLong(this, R.string.failed_to_import_url);
});
} catch (Exception e) {
Logger.logError("Failed to process import input: " + fileName, e);
ToastHelper.showLong(this, R.string.failed_to_import_url);
}
}
public void importMultipleFilesFromUris(@NonNull List<Uri> uris) {
if (uris.isEmpty()) return;
if (uris.size() == 1) {
Uri singleUri = uris.get(0);
String fileName = getFileNameFromUri(singleUri);
String content = readContentsOfUri(singleUri);
if (content != null && !content.trim().isEmpty()) {
importMonsterFromInputAndNavigate(content, fileName);
} else {
ToastHelper.showLong(this, R.string.failed_to_import_url);
}
return;
}
ToastHelper.showShort(this, R.string.toast_importing_url);
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);
ToastHelper.showLong(this, R.string.failed_to_import_url);
});
Single.fromCallable(() -> {
int monstersImported = 0;
int bindersImported = 0;
for (Uri uri : uris) {
try {
String fileName = getFileNameFromUri(uri);
String content = readContentsOfUri(uri);
if (content == null || content.trim().isEmpty()) {
continue;
}
BinderImporter binderImporter = new BinderImporter();
if (binderImporter.canImport(content)) {
BinderExport binder = binderImporter.parse(content);
((MonsterCardsApplication) getApplication()).getMonsterRepository()
.importBinder(binder)
.blockingAwait();
bindersImported++;
} else {
Monster monster = MonsterImportHelper.fromJSON(content, fileName);
((MonsterCardsApplication) getApplication()).getMonsterRepository()
.saveMonster(monster)
.blockingAwait();
monstersImported++;
}
} catch (Exception e) {
Logger.logError("Failed to import file URI: " + uri, e);
}
}
return new BatchImportResult(monstersImported, bindersImported);
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
if (result.bindersImported > 0 || result.monstersImported > 0) {
String message = buildImportSummaryMessage(result.monstersImported, result.bindersImported);
ToastHelper.showLong(this, message);
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
NavController navController = navHostFragment.getNavController();
navController.navigate(R.id.navigation_library);
} else {
ToastHelper.showLong(this, R.string.failed_to_import_url);
}
}, throwable -> {
Logger.logError("Failed to execute batch file import", throwable);
ToastHelper.showLong(this, R.string.failed_to_import_url);
});
}
private String buildImportSummaryMessage(int monstersCount, int bindersCount) {
StringBuilder sb = new StringBuilder("Successfully imported ");
if (bindersCount > 0 && monstersCount > 0) {
sb.append(bindersCount).append(bindersCount == 1 ? " collection" : " collections")
.append(" and ")
.append(monstersCount).append(monstersCount == 1 ? " monster" : " monsters");
} else if (bindersCount > 0) {
sb.append(bindersCount).append(bindersCount == 1 ? " collection" : " collections");
} else {
sb.append(monstersCount).append(monstersCount == 1 ? " monster" : " monsters");
}
return sb.toString();
}
private static class BatchImportResult {
final int monstersImported;
final int bindersImported;
BatchImportResult(int monstersImported, int bindersImported) {
this.monstersImported = monstersImported;
this.bindersImported = bindersImported;
}
}
@Nullable

View File

@@ -47,9 +47,18 @@ public interface CollectionDAO {
@Query("DELETE FROM collection_monsters WHERE collection_id = :collectionId AND monster_id = :monsterId")
Completable removeMonsterFromCollection(String collectionId, String monsterId);
@Query("DELETE FROM collection_monsters WHERE collection_id = :collectionId")
Completable removeAllMonstersFromCollection(String collectionId);
@Query("DELETE FROM collection_monsters WHERE id = :id")
Completable removeCollectionMonsterById(long id);
@Query("DELETE FROM collection_monsters")
Completable deleteAllCollectionMonsters();
@Query("DELETE FROM collections")
Completable deleteAllCollections();
@Update
Completable updateCollectionMonsters(List<CollectionMonster> collectionMonsters);
}

View File

@@ -36,4 +36,7 @@ public interface MonsterDAO {
@Delete
Completable delete(Monster monster);
@Query("DELETE FROM monsters")
Completable deleteAllMonsters();
}

View File

@@ -9,18 +9,22 @@ import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionMonster;
import com.majinnaibu.monstercards.models.CollectionWithCount;
import com.majinnaibu.monstercards.models.DashboardMonster;
import com.majinnaibu.monstercards.models.DashboardMonsterWithMonster;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.SearchResultItem;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@SuppressWarnings("ResultOfMethodCallIgnored")
@@ -264,6 +268,91 @@ public class MonsterRepository {
return result;
}
public Completable clearAllData() {
return Completable.fromAction(() -> {
m_db.dashboardDAO().clearDashboard().blockingAwait();
m_db.collectionDAO().deleteAllCollectionMonsters().blockingAwait();
m_db.collectionDAO().deleteAllCollections().blockingAwait();
m_db.monsterDAO().deleteAllMonsters().blockingAwait();
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
public Completable removeAllCollections() {
return Completable.fromAction(() -> {
m_db.collectionDAO().deleteAllCollectionMonsters().blockingAwait();
m_db.collectionDAO().deleteAllCollections().blockingAwait();
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
public Completable removeAllMonstersFromCollection(@NonNull UUID collectionId) {
Completable result = m_db.collectionDAO().removeAllMonstersFromCollection(collectionId.toString());
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Single<String> exportCollections() {
return m_db.collectionDAO().getAll().first(new ArrayList<>())
.map(collections -> {
List<BinderExport.CollectionExport> colExports = new ArrayList<>();
for (Collection col : collections) {
List<Monster> colMonsters = m_db.collectionDAO().getMonstersForCollection(col.id.toString())
.first(new ArrayList<>()).blockingGet();
colExports.add(new BinderExport.CollectionExport(
col.id.toString(),
col.name,
col.description,
colMonsters
));
}
return new com.majinnaibu.monstercards.exporters.BinderExporter().exportFullBackup(colExports, null);
})
.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
public Single<String> exportEverything() {
return Single.zip(
m_db.collectionDAO().getAll().first(new ArrayList<>()),
m_db.monsterDAO().getAll().first(new ArrayList<>()),
m_db.dashboardDAO().getDashboardMonsters().first(new ArrayList<>()),
(collections, allMonsters, dashboardMonsters) -> {
List<BinderExport.CollectionExport> colExports = new ArrayList<>();
Set<UUID> monstersInCollections = new HashSet<>();
for (Collection col : collections) {
List<Monster> colMonsters = m_db.collectionDAO().getMonstersForCollection(col.id.toString())
.first(new ArrayList<>()).blockingGet();
for (Monster m : colMonsters) {
monstersInCollections.add(m.id);
}
colExports.add(new BinderExport.CollectionExport(
col.id.toString(),
col.name,
col.description,
colMonsters
));
}
List<Monster> uncategorized = new ArrayList<>();
for (Monster m : allMonsters) {
if (!monstersInCollections.contains(m.id)) {
uncategorized.add(m);
}
}
if (!uncategorized.isEmpty() || colExports.isEmpty()) {
colExports.add(new BinderExport.CollectionExport(
UUID.randomUUID().toString(),
colExports.isEmpty() ? "All Monsters" : "Uncategorized",
"",
colExports.isEmpty() ? allMonsters : uncategorized
));
}
return new com.majinnaibu.monstercards.exporters.BinderExporter().exportFullBackup(colExports, dashboardMonsters);
}
).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
public Completable updateDashboardMonsters(List<DashboardMonster> items) {
Completable result = m_db.dashboardDAO().updateDashboardMonsters(items);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
@@ -272,65 +361,164 @@ public class MonsterRepository {
public Completable importBinder(@NonNull BinderExport binder) {
return Completable.fromAction(() -> {
if (binder.collections == null || binder.collections.isEmpty()) {
return;
}
// 1. Process Collections & Cards in Binder
if (binder.collections != null && !binder.collections.isEmpty()) {
List<Collection> existingCols = m_db.collectionDAO().getAll().first(new ArrayList<>()).blockingGet();
for (BinderExport.CollectionExport colExport : binder.collections) {
if (colExport.cards == null || colExport.cards.isEmpty()) {
continue;
}
List<Monster> monstersToSave = new ArrayList<>();
for (Monster card : colExport.cards) {
if (card.id == null) {
card.id = UUID.randomUUID();
for (BinderExport.CollectionExport colExport : binder.collections) {
if (colExport == null) {
continue;
}
String colName = colExport.name != null ? colExport.name.trim() : "";
String colDesc = colExport.description != null ? colExport.description.trim() : "";
UUID colExportId = null;
if (colExport.id != null && !colExport.id.trim().isEmpty()) {
try {
colExportId = UUID.fromString(colExport.id.trim());
} catch (Exception ignored) {
}
}
monstersToSave.add(card);
}
m_db.monsterDAO().save(monstersToSave.toArray(new Monster[0])).blockingAwait();
String colName = colExport.name != null ? colExport.name.trim() : "";
if (!colName.isEmpty()) {
List<Collection> existingCols = m_db.collectionDAO().getAll().first(new ArrayList<>()).blockingGet();
Collection targetCollection = null;
for (Collection existing : existingCols) {
if (existing.name != null && existing.name.equalsIgnoreCase(colName)) {
targetCollection = existing;
break;
if (colExportId != null) {
for (Collection existing : existingCols) {
if (existing.id.equals(colExportId)) {
targetCollection = existing;
break;
}
}
}
if (targetCollection == null && !colName.isEmpty()) {
for (Collection existing : existingCols) {
if (existing.name != null && existing.name.equalsIgnoreCase(colName)) {
targetCollection = existing;
break;
}
}
}
UUID targetCollectionId;
if (targetCollection != null) {
targetCollectionId = targetCollection.id;
if (!colDesc.isEmpty() && (targetCollection.description == null || targetCollection.description.isEmpty())) {
targetCollection.description = colDesc;
m_db.collectionDAO().save(targetCollection).blockingAwait();
}
} else {
Collection newCol = new Collection();
newCol.id = UUID.randomUUID();
newCol.name = colName;
newCol.id = colExportId != null ? colExportId : UUID.randomUUID();
newCol.name = !colName.isEmpty() ? colName : "Imported Collection";
newCol.description = colDesc;
m_db.collectionDAO().save(newCol).blockingAwait();
targetCollectionId = newCol.id;
existingCols.add(newCol);
}
List<Monster> colMonsters = m_db.collectionDAO().getMonstersForCollection(targetCollectionId.toString())
.first(new ArrayList<>()).blockingGet();
Set<UUID> existingMonsterIds = new HashSet<>();
for (Monster m : colMonsters) {
existingMonsterIds.add(m.id);
}
if (colExport.cards != null && !colExport.cards.isEmpty()) {
List<Monster> monstersToSave = new ArrayList<>();
Map<UUID, Integer> importedCardCounts = new HashMap<>();
int ordinal = colMonsters.size();
for (Monster monster : monstersToSave) {
if (!existingMonsterIds.contains(monster.id)) {
m_db.collectionDAO().addMonsterToCollection(new CollectionMonster(targetCollectionId, monster.id, ordinal++)).blockingAwait();
existingMonsterIds.add(monster.id);
for (Monster card : colExport.cards) {
if (card != null) {
if (card.id == null) {
card.id = UUID.randomUUID();
}
monstersToSave.add(card);
importedCardCounts.put(card.id, getCount(importedCardCounts, card.id) + 1);
}
}
if (!monstersToSave.isEmpty()) {
m_db.monsterDAO().save(monstersToSave.toArray(new Monster[0])).blockingAwait();
List<Monster> existingColMonsters = m_db.collectionDAO().getMonstersForCollection(targetCollectionId.toString())
.first(new ArrayList<>()).blockingGet();
Map<UUID, Integer> existingCardCounts = new HashMap<>();
for (Monster m : existingColMonsters) {
existingCardCounts.put(m.id, getCount(existingCardCounts, m.id) + 1);
}
int ordinal = existingColMonsters.size();
for (Map.Entry<UUID, Integer> entry : importedCardCounts.entrySet()) {
UUID monsterId = entry.getKey();
int importedCount = entry.getValue();
int existingCount = getCount(existingCardCounts, monsterId);
if (importedCount > existingCount) {
int extraCopiesNeeded = importedCount - existingCount;
for (int k = 0; k < extraCopiesNeeded; k++) {
m_db.collectionDAO().addMonsterToCollection(new CollectionMonster(targetCollectionId, monsterId, ordinal++)).blockingAwait();
}
}
}
}
}
}
}
// 2. Process Dashboard entries in Binder if present
if (binder.dashboard != null && !binder.dashboard.isEmpty()) {
List<Monster> dashboardMonstersToSave = new ArrayList<>();
Map<UUID, Integer> importedDashboardCounts = new HashMap<>();
for (Monster card : binder.dashboard) {
if (card != null) {
if (card.id == null) {
card.id = UUID.randomUUID();
}
dashboardMonstersToSave.add(card);
importedDashboardCounts.put(card.id, getCount(importedDashboardCounts, card.id) + 1);
}
}
if (!dashboardMonstersToSave.isEmpty()) {
m_db.monsterDAO().save(dashboardMonstersToSave.toArray(new Monster[0])).blockingAwait();
List<DashboardMonsterWithMonster> existingDashboard = m_db.dashboardDAO().getDashboardMonstersWithMonster()
.first(new ArrayList<>()).blockingGet();
Map<UUID, Integer> existingDashboardCounts = new HashMap<>();
int currentMaxOrdinal = 0;
for (DashboardMonsterWithMonster entry : existingDashboard) {
if (entry.monster != null) {
existingDashboardCounts.put(entry.monster.id, getCount(existingDashboardCounts, entry.monster.id) + 1);
}
if (entry.dashboardEntry != null && entry.dashboardEntry.ordinal > currentMaxOrdinal) {
currentMaxOrdinal = entry.dashboardEntry.ordinal;
}
}
int ordinal = currentMaxOrdinal + 1;
List<DashboardMonster> extraEntriesToAdd = new ArrayList<>();
for (Map.Entry<UUID, Integer> entry : importedDashboardCounts.entrySet()) {
UUID monsterId = entry.getKey();
int importedCount = entry.getValue();
int existingCount = getCount(existingDashboardCounts, monsterId);
if (importedCount > existingCount) {
int extraNeeded = importedCount - existingCount;
for (int k = 0; k < extraNeeded; k++) {
extraEntriesToAdd.add(new DashboardMonster(monsterId, ordinal++));
}
}
}
if (!extraEntriesToAdd.isEmpty()) {
m_db.dashboardDAO().addMonsterToDashboard(extraEntriesToAdd.toArray(new DashboardMonster[0])).blockingAwait();
}
}
}
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
private static int getCount(Map<UUID, Integer> map, UUID key) {
if (map == null || key == null) return 0;
Integer val = map.get(key);
return val != null ? val : 0;
}
private static class Helpers {
static boolean monsterMatchesSearch(Monster monster, String searchText) {
if (StringHelper.isNullOrEmpty(searchText)) {

View File

@@ -21,11 +21,23 @@ public enum AbilityScore {
}
public static AbilityScore valueOfString(String string) {
if (string == null) {
return AbilityScore.STRENGTH;
}
String lower = string.trim().toLowerCase(java.util.Locale.ROOT);
for (AbilityScore abilityScore : values()) {
if (abilityScore.stringValue.equals(string)) {
if (abilityScore.stringValue.equalsIgnoreCase(lower)
|| abilityScore.shortDisplayName.equalsIgnoreCase(lower)
|| abilityScore.name().equalsIgnoreCase(lower)) {
return abilityScore;
}
}
if (lower.startsWith("str")) return STRENGTH;
if (lower.startsWith("dex")) return DEXTERITY;
if (lower.startsWith("con")) return CONSTITUTION;
if (lower.startsWith("int")) return INTELLIGENCE;
if (lower.startsWith("wis")) return WISDOM;
if (lower.startsWith("cha")) return CHARISMA;
return AbilityScore.STRENGTH;
}
}

View File

@@ -1,34 +1,94 @@
package com.majinnaibu.monstercards.exporters;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.majinnaibu.monstercards.models.BinderExport;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
public class BinderExporter {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@NonNull
public String exportBinder(String collectionName, List<Monster> monsters) {
return exportBinder(new Collection(collectionName != null ? collectionName : "", ""), monsters, null);
}
@NonNull
public String exportBinder(@Nullable Collection collection, List<Monster> monsters) {
return exportBinder(collection, monsters, null);
}
@NonNull
public String exportBinder(@Nullable Collection collection, @Nullable List<Monster> monsters, @Nullable List<Monster> dashboardMonsters) {
BinderExport export = new BinderExport();
export.schema = "https://majinnaibu.com/schemas/binder.schema.json";
export.schemaVersion = 1;
if (monsters != null) {
for (Monster monster : monsters) {
monster.schema = "https://majinnaibu.com/schemas/monster-card.schema.json";
monster.schemaVersion = 1;
}
}
BinderExport.CollectionExport col = new BinderExport.CollectionExport(
collectionName != null ? collectionName : "",
monsters
);
export.collections = Collections.singletonList(col);
if (dashboardMonsters != null && !dashboardMonsters.isEmpty()) {
for (Monster monster : dashboardMonsters) {
monster.schema = "https://majinnaibu.com/schemas/monster-card.schema.json";
monster.schemaVersion = 1;
}
export.dashboard = dashboardMonsters;
}
if (collection != null) {
String colId = collection.id.toString();
String colName = collection.name;
String colDesc = collection.description;
BinderExport.CollectionExport col = new BinderExport.CollectionExport(
colId,
colName,
colDesc,
monsters
);
export.collections = Collections.singletonList(col);
}
return GSON.toJson(export);
}
@NonNull
public String exportFullBackup(@Nullable List<BinderExport.CollectionExport> collections, @Nullable List<Monster> dashboardMonsters) {
BinderExport export = new BinderExport();
export.schema = "https://majinnaibu.com/schemas/binder.schema.json";
export.schemaVersion = 1;
if (collections != null) {
for (BinderExport.CollectionExport col : collections) {
if (col.cards != null) {
for (Monster m : col.cards) {
m.schema = "https://majinnaibu.com/schemas/monster-card.schema.json";
m.schemaVersion = 1;
}
}
}
export.collections = collections;
}
if (dashboardMonsters != null && !dashboardMonsters.isEmpty()) {
for (Monster m : dashboardMonsters) {
m.schema = "https://majinnaibu.com/schemas/monster-card.schema.json";
m.schemaVersion = 1;
}
export.dashboard = dashboardMonsters;
}
return GSON.toJson(export);
}
}

View File

@@ -11,6 +11,7 @@ public class MonsterCardExporter {
@NonNull
public String exportCard(@NonNull Monster monster) {
monster.schema = "https://majinnaibu.com/schemas/monster-card.schema.json";
monster.schemaVersion = 1;
return GSON.toJson(monster);
}

View File

@@ -1,39 +1,85 @@
package com.majinnaibu.monstercards.helpers;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.importers.EntityImporter;
import com.majinnaibu.monstercards.importers.MonsterJsonImporter;
import com.majinnaibu.monstercards.importers.Open5eImporter;
import com.majinnaibu.monstercards.importers.TetraCubeMonsterImporter;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.utils.Logger;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class MonsterImportHelper {
private static final List<EntityImporter<Monster>> IMPORTERS = new ArrayList<>();
static {
IMPORTERS.add(new TetraCubeMonsterImporter());
IMPORTERS.add(new MonsterJsonImporter());
IMPORTERS.add(new Open5eImporter());
IMPORTERS.add(new TetraCubeMonsterImporter());
IMPORTERS.add(new DnDBeyondImporter());
}
@NonNull
public static Monster fromJSON(String json) {
if (json != null) {
for (EntityImporter<Monster> importer : IMPORTERS) {
if (importer.canImport(json)) {
try {
return importer.parse(json);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse monster JSON", e);
return fromJSON(json, null);
}
@NonNull
public static Monster fromJSON(String json, @Nullable String fileName) {
if (json == null || json.trim().isEmpty()) {
throw new IllegalArgumentException("JSON payload is null or empty");
}
// 1. Check for explicit $schema reference matching monster-card or tetracube schema
try {
JsonElement el = JsonParser.parseString(json);
if (el.isJsonObject()) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("$schema")) {
String schemaVal = obj.get("$schema").getAsString();
if (schemaVal.contains("monster-card.schema.json")) {
return new MonsterJsonImporter().parse(json);
} else if (schemaVal.contains("tetracube-monster.schema.json")) {
return new TetraCubeMonsterImporter().parse(json);
}
}
}
} catch (Exception e) {
Logger.logError("Error checking $schema reference tag", e);
}
// 2. If filename ends in .monster, try TetraCube importer first
String lowerFileName = fileName != null ? fileName.toLowerCase(Locale.ROOT) : "";
boolean isMonsterFile = lowerFileName.endsWith(".monster") || lowerFileName.endsWith(".monster.txt");
if (isMonsterFile) {
TetraCubeMonsterImporter tetraImporter = new TetraCubeMonsterImporter();
if (tetraImporter.canImport(json)) {
try {
return tetraImporter.parse(json);
} catch (Exception e) {
Logger.logError("TetraCube importer failed for .monster file: " + fileName, e);
}
}
}
// 3. Fallback to current importer detection chain
for (EntityImporter<Monster> importer : IMPORTERS) {
if (importer.canImport(json)) {
try {
return importer.parse(json);
} catch (Exception e) {
Logger.logError("Importer " + importer.getClass().getSimpleName() + " failed", e);
}
}
}
throw new IllegalArgumentException("No importer found capable of parsing the given JSON payload.");
}

View File

@@ -18,6 +18,12 @@ public class BinderImporter implements EntityImporter<BinderExport> {
JsonElement el = JsonParser.parseString(input);
if (el.isJsonObject()) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("$schema")) {
String schemaVal = obj.get("$schema").getAsString();
if (schemaVal.contains("binder.schema.json")) {
return true;
}
}
return obj.has("collections") && obj.get("collections").isJsonArray();
}
} catch (Exception ignored) {

View File

@@ -18,7 +18,13 @@ public class MonsterJsonImporter implements EntityImporter<Monster> {
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"));
if (obj.has("$schema")) {
String schemaVal = obj.get("$schema").getAsString();
if (schemaVal.contains("monster-card.schema.json")) {
return true;
}
}
return obj.has("name") && (obj.has("strengthScore") || obj.has("strengthSavingThrowProficiency"));
}
} catch (Exception ignored) {
}

View File

@@ -36,6 +36,12 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
return false;
}
JsonObject rootDict = element.getAsJsonObject();
if (rootDict.has("$schema")) {
String schemaVal = rootDict.get("$schema").getAsString();
if (schemaVal.contains("tetracube-monster.schema.json")) {
return true;
}
}
return rootDict.has("armorName") || rootDict.has("strPoints")
|| rootDict.has("isLegendary") || rootDict.has("isMythic")
|| rootDict.has("legendariesDescription");

View File

@@ -4,27 +4,46 @@ import com.google.gson.annotations.SerializedName;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class BinderExport {
@SerializedName("$schema")
public String schema = "https://majinnaibu.com/schemas/binder.schema.json";
@SerializedName("schemaVersion")
public int schemaVersion = 1;
@SerializedName("collections")
public List<CollectionExport> collections = new ArrayList<>();
@SerializedName("dashboard")
public List<Monster> dashboard = new ArrayList<>();
public static class CollectionExport {
@SerializedName("id")
public String id;
@SerializedName("name")
public String name;
@SerializedName("description")
public String description;
@SerializedName("cards")
public List<Monster> cards = new ArrayList<>();
public CollectionExport() {
}
public CollectionExport(String name, List<Monster> cards) {
public CollectionExport(String id, String name, String description, List<Monster> cards) {
this.id = id != null ? id : UUID.randomUUID().toString();
this.name = name != null ? name : "";
this.description = description != null ? description : "";
this.cards = cards != null ? cards : new ArrayList<>();
}
public CollectionExport(String name, List<Monster> cards) {
this(UUID.randomUUID().toString(), name, "", cards);
}
}
}

View File

@@ -9,6 +9,7 @@ import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import com.google.gson.annotations.SerializedName;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.data.enums.AdvantageType;
import com.majinnaibu.monstercards.data.enums.ArmorType;
@@ -30,11 +31,16 @@ import java.util.UUID;
@SuppressWarnings("unused")
public class Monster {
@SerializedName("$schema")
@Ignore
public String schema = "https://majinnaibu.com/schemas/monster-card.schema.json";
@Ignore
public int schemaVersion = 1;
@PrimaryKey
@NonNull
@SerializedName("id")
public UUID id;
@NonNull
@@ -438,7 +444,10 @@ public class Monster {
}
}
public int getAbilityModifier(@NonNull AbilityScore abilityScore) {
public int getAbilityModifier(@Nullable AbilityScore abilityScore) {
if (abilityScore == null) {
return 0;
}
switch (abilityScore) {
case STRENGTH:
return getStrengthModifier();

View File

@@ -34,13 +34,25 @@ public class Skill implements Comparator<Skill>, Comparable<Skill> {
this.proficiencyType = proficiencyType;
}
public AbilityScore getAbilityScore() {
if (abilityScore != null) {
return abilityScore;
}
if (name != null) {
return AbilityScore.valueOfString(name);
}
return AbilityScore.STRENGTH;
}
public int getSkillBonus(Monster monster) {
int modifier = monster.getAbilityModifier(abilityScore);
switch (proficiencyType) {
AbilityScore score = getAbilityScore();
int modifier = monster != null ? monster.getAbilityModifier(score) : 0;
ProficiencyType prof = proficiencyType != null ? proficiencyType : ProficiencyType.PROFICIENT;
switch (prof) {
case PROFICIENT:
return modifier + monster.getProficiencyBonus();
return modifier + (monster != null ? monster.getProficiencyBonus() : 0);
case EXPERTISE:
return modifier + monster.getProficiencyBonus() * 2;
return modifier + (monster != null ? monster.getProficiencyBonus() * 2 : 0);
case NONE:
default:
return modifier;

View File

@@ -221,10 +221,32 @@ public class CollectionDetailFragment extends MCFragment {
} else if (item.getItemId() == R.id.menu_action_export_collection) {
exportCollection();
return true;
} else if (item.getItemId() == R.id.menu_action_remove_all_monsters_from_collection) {
showRemoveAllMonstersConfirmationDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
private void showRemoveAllMonstersConfirmationDialog() {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.dialog_remove_all_from_collection_title)
.setMessage(R.string.dialog_remove_all_from_collection_message)
.setPositiveButton(R.string.action_remove_all, (dialog, which) -> {
mDisposables.add(getMonsterRepository().removeAllMonstersFromCollection(mCollectionId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
SnackbarHelper.showLong(view, R.string.snackbar_collection_cleared);
}
}, throwable -> Logger.logError("Failed to remove all monsters from collection", throwable)));
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}
private void exportCollection() {
Collection collection = mViewModel.getCollection().getValue();
List<Monster> monsters = mViewModel.getMonsters().getValue();

View File

@@ -4,6 +4,9 @@ import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
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;
@@ -43,6 +46,7 @@ public class CollectionsFragment extends MCFragment {
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
setHasOptionsMenu(true);
View root = inflater.inflate(R.layout.fragment_collections, container, false);
FloatingActionButton fab = root.findViewById(R.id.fab_add_collection);
@@ -190,6 +194,51 @@ public class CollectionsFragment extends MCFragment {
Navigation.findNavController(requireView()).navigate(action);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.collections_menu, menu);
super.onCreateOptionsMenu(menu, inflater);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.menu_action_export_collections) {
exportCollections();
return true;
} else if (item.getItemId() == R.id.menu_action_remove_all_collections) {
showRemoveAllCollectionsConfirmationDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
private void exportCollections() {
mDisposables.add(getMonsterRepository().exportCollections()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(json -> exportToFile("collections.binder", json),
throwable -> Logger.logError("Failed to export collections", throwable)));
}
private void showRemoveAllCollectionsConfirmationDialog() {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.dialog_remove_all_collections_title)
.setMessage(R.string.dialog_remove_all_collections_message)
.setPositiveButton(R.string.action_remove_all, (dialog, which) -> {
mDisposables.add(getMonsterRepository().removeAllCollections()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
SnackbarHelper.showLong(view, R.string.snackbar_all_collections_removed);
}
}, throwable -> Logger.logError("Failed to remove all collections", throwable)));
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}
@Override
public void onDestroyView() {
super.onDestroyView();

View File

@@ -24,6 +24,7 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionWithCount;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.utils.Logger;
@@ -265,7 +266,7 @@ public class DashboardFragment extends MCFragment {
private void showAddCollectionPicker() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getCollections()
mDisposables.add(repository.getCollectionsWithCount()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
@@ -279,12 +280,16 @@ public class DashboardFragment extends MCFragment {
}
String[] names = new String[collections.size()];
for (int i = 0; i < collections.size(); i++) {
names[i] = collections.get(i).name;
CollectionWithCount item = collections.get(i);
int count = item.monsterCount;
String countText = count == 1 ? "1 monster" : count + " monsters";
String colName = item.collection != null && item.collection.name != null ? item.collection.name : "";
names[i] = colName + " (" + countText + ")";
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.action_add_collection_option)
.setItems(names, (dialog, which) -> {
Collection selected = collections.get(which);
Collection selected = collections.get(which).collection;
addCollectionToDashboard(selected);
})
.setNegativeButton(R.string.dialog_cancel, null)
@@ -365,9 +370,6 @@ public class DashboardFragment extends MCFragment {
} else if (item.getItemId() == R.id.menu_action_add_collection_option) {
showAddCollectionPicker();
return true;
} else if (item.getItemId() == R.id.menu_action_remove_single_monster) {
showRemoveMonsterPicker();
return true;
} else if (item.getItemId() == R.id.menu_action_clear_dashboard) {
clearDashboard();
return true;

View File

@@ -85,10 +85,35 @@ public class LibraryFragment extends MCFragment {
} else if (item.getItemId() == R.id.menu_action_export_library) {
exportLibrary();
return true;
} else if (item.getItemId() == R.id.menu_action_export_everything) {
exportEverything();
return true;
} else if (item.getItemId() == R.id.menu_action_clear_all_data) {
showClearAllDataConfirmationDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
private void showClearAllDataConfirmationDialog() {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.dialog_clear_all_data_title)
.setMessage(R.string.dialog_clear_all_data_message)
.setPositiveButton(R.string.action_clear, (dialog, which) -> {
mDisposables.add(getMonsterRepository().clearAllData()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
SnackbarHelper.showLong(view, R.string.snackbar_all_data_cleared);
}
}, throwable -> Logger.logError("Failed to clear all data", throwable)));
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}
private void exportLibrary() {
getMonsterRepository().getMonsters()
.firstOrError()
@@ -101,6 +126,16 @@ public class LibraryFragment extends MCFragment {
}, Logger::logError);
}
private void exportEverything() {
mDisposables.add(getMonsterRepository().exportEverything()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(json -> {
String fileName = getString(R.string.default_filename_export_everything) + ".binder";
exportToFile(fileName, json);
}, throwable -> Logger.logError("Failed to export everything", throwable)));
}
private void setupRecyclerView(@NonNull RecyclerView recyclerView, @Nullable View emptyState) {
Context context = requireContext();
MonsterRepository repository = this.getMonsterRepository();

View File

@@ -40,6 +40,8 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@@ -71,17 +73,22 @@ public class MCFragment extends Fragment {
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
if (uri != null && getActivity() instanceof MainActivity) {
String content = readContentsFromUri(uri);
if (content != null && !content.trim().isEmpty()) {
((MainActivity) getActivity()).importMonsterFromInputAndNavigate(content);
} else {
View view = getView();
if (view != null) {
SnackbarHelper.showLong(view, R.string.failed_to_import_url);
Intent data = result.getData();
List<Uri> uris = new ArrayList<>();
if (data.getClipData() != null) {
ClipData clipData = data.getClipData();
for (int i = 0; i < clipData.getItemCount(); i++) {
Uri uri = clipData.getItemAt(i).getUri();
if (uri != null) {
uris.add(uri);
}
}
} else if (data.getData() != null) {
uris.add(data.getData());
}
if (!uris.isEmpty() && getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importMultipleFilesFromUris(uris);
}
}
});
@@ -91,6 +98,7 @@ public class MCFragment extends Fragment {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
try {
mOpenDocumentLauncher.launch(intent);
} catch (Exception e) {

View File

@@ -11,4 +11,9 @@
android:id="@+id/menu_action_export_collection"
android:title="@string/action_export_collection"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_remove_all_monsters_from_collection"
android:title="@string/action_remove_all"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_action_export_collections"
android:title="@string/action_export"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_remove_all_collections"
android:title="@string/action_remove_all"
app:showAsAction="never" />
</menu>

View File

@@ -12,11 +12,6 @@
android:title="@string/action_add_collection_option"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_remove_single_monster"
android:title="@string/action_remove_single_monster"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_clear_dashboard"
android:title="@string/action_clear_dashboard"

View File

@@ -16,4 +16,14 @@
android:id="@+id/menu_action_export_library"
android:title="@string/action_export_library"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_export_everything"
android:title="@string/action_export_everything"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_clear_all_data"
android:title="@string/action_clear_all_data"
app:showAsAction="never" />
</menu>

View File

@@ -11,7 +11,7 @@
<string name="action_add_skill">Add Skill</string>
<string name="action_add_trait">Add Trait</string>
<string name="action_edit">Edit</string>
<string name="action_import_monster">Import Monster</string>
<string name="action_import_monster">Import File</string>
<string name="app_name">MonsterCards</string>
<string name="charisma_abbreviation">CHA</string>
<string name="constitution_abbreviation">CON</string>
@@ -133,7 +133,7 @@
<string name="title_editStrings">Strings</string>
<string name="title_editTrait">Trait</string>
<string name="title_editTraits">Traits</string>
<string name="title_importMonster">Import Monster</string>
<string name="title_importMonster">Import File</string>
<string name="title_library">Library</string>
<string name="title_monsterDetails">Monster Details</string>
<string name="title_monsterDetails_fmt">%1$s Details</string>
@@ -156,25 +156,25 @@
<string name="action_add_collection_to_dashboard">Add Collection to Dashboard</string>
<string name="action_add_single_monster">Add Monster</string>
<string name="action_add_collection_option">Add Collection</string>
<string name="action_clear_dashboard">Clear Dashboard</string>
<string name="action_clear_dashboard">Clear</string>
<string name="dialog_add_to_dashboard">Add to Dashboard</string>
<string name="snackbar_added_to_dashboard">Added %1$s to Dashboard</string>
<string name="snackbar_collection_added_to_dashboard">Added collection %1$s to Dashboard</string>
<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="dialog_import_url_title">Import Entity or Character</string>
<string name="action_import_from_url">Import URL</string>
<string name="dialog_import_url_title">Import Character</string>
<string name="dialog_import_url_message">Enter or paste a D&amp;D Beyond URL, Character ID, or Open5e JSON payload:</string>
<string name="dialog_import_url_hint">e.g. D&amp;D Beyond URL or Open5e JSON</string>
<string name="dialog_import">Import</string>
<string name="toast_importing_url">Importing entity...</string>
<string name="failed_to_import_url">Failed to import entity. Please check the URL, ID, or JSON format.</string>
<string name="toast_importing_url">Importing…</string>
<string name="failed_to_import_url">Failed to import URL. Please check the URL, ID, or JSON format.</string>
<string name="action_share_monster">Share / Export Monster (.card)</string>
<string name="failed_to_share_monster">Failed to share monster</string>
<string name="action_export_card">Export Card</string>
<string name="action_export_collection">Export Collection</string>
<string name="action_export_library">Export Library</string>
<string name="action_export_dashboard">Export Dashboard</string>
<string name="action_export_dashboard">Export</string>
<string name="snackbar_export_success">Exported to %1$s</string>
<string name="snackbar_export_failed">Failed to export file</string>
<string name="snackbar_import_binder_success">Imported binder successfully</string>
@@ -189,7 +189,7 @@
<string name="empty_library_subtitle">Create new monster cards or import them from D&amp;D Beyond or Open5e.</string>
<string name="app_title_splash">Monster Cards</string>
<string name="action_remove_single_monster">Remove Monster...</string>
<string name="action_remove_single_monster">Remove Monster</string>
<string name="action_remove">Remove</string>
<string name="action_undo">Undo</string>
<string name="action_view_details">View Details</string>
@@ -200,10 +200,29 @@
<string name="dialog_remove_from_dashboard_message">Remove %1$s from your dashboard?</string>
<string name="action_create_monster">Create Monster</string>
<string name="action_import_monster_from_url">Import Monster from Url</string>
<string name="action_import_monster_from_file">Import Monster from File</string>
<string name="action_import_collection">Import Collection</string>
<string name="action_import_monster_from_url">Import URL</string>
<string name="action_import_monster_from_file">Import File</string>
<string name="action_import_collection">Import File</string>
<string name="title_library_actions">Library Actions</string>
<string name="title_collection_actions">Collection Actions</string>
<string name="title_dashboard_actions">Dashboard Actions</string>
<string name="action_clear">Clear</string>
<string name="action_clear_all_data">Clear All Data</string>
<string name="dialog_clear_all_data_title">Clear All Data?</string>
<string name="dialog_clear_all_data_message">This will permanently delete all monsters, collections, and clear your dashboard. Are you sure?</string>
<string name="snackbar_all_data_cleared">All data cleared</string>
<string name="action_export_everything">Export All Data</string>
<string name="default_filename_export_everything">monster_cards_backup</string>
<string name="action_export">Export</string>
<string name="action_remove_all">Remove All</string>
<string name="dialog_remove_all_collections_title">Remove All Collections?</string>
<string name="dialog_remove_all_collections_message">Are you sure you want to remove all collections? Monsters in your library will not be deleted.</string>
<string name="snackbar_all_collections_removed">All collections removed</string>
<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>
</resources>