Commit 791dd602 authored by Lückemeyer's avatar Lückemeyer
Browse files

claude added export functionality, 2 iterations

parent bc2dc6fb
...@@ -5,6 +5,9 @@ ...@@ -5,6 +5,9 @@
<!-- Needed on API 26–28 to read files from Downloads via file picker --> <!-- Needed on API 26–28 to read files from Downloads via file picker -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<!-- Needed on API 26–28 to write files via ACTION_CREATE_DOCUMENT -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application <application
android:name=".VoCoachApp" android:name=".VoCoachApp"
......
...@@ -4,10 +4,8 @@ import android.app.AlertDialog; ...@@ -4,10 +4,8 @@ import android.app.AlertDialog;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.Toast; import android.widget.Toast;
...@@ -19,10 +17,14 @@ import androidx.navigation.fragment.NavHostFragment; ...@@ -19,10 +17,14 @@ import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI; import androidx.navigation.ui.NavigationUI;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import dev.lueckemeyer.vocoach.databinding.ActivityMainBinding; import dev.lueckemeyer.vocoach.databinding.ActivityMainBinding;
import dev.lueckemeyer.vocoach.importer.CsvExporter;
import dev.lueckemeyer.vocoach.importer.CsvImporter; import dev.lueckemeyer.vocoach.importer.CsvImporter;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
...@@ -30,7 +32,7 @@ public class MainActivity extends AppCompatActivity { ...@@ -30,7 +32,7 @@ public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding; private ActivityMainBinding binding;
private final ExecutorService exec = Executors.newSingleThreadExecutor(); private final ExecutorService exec = Executors.newSingleThreadExecutor();
// ── File picker launcher ────────────────────────────────────────────────── // ── File picker (import) ──────────────────────────────────────────────────
private final ActivityResultLauncher<Intent> filePickerLauncher = private final ActivityResultLauncher<Intent> filePickerLauncher =
registerForActivityResult( registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), new ActivityResultContracts.StartActivityForResult(),
...@@ -41,6 +43,17 @@ public class MainActivity extends AppCompatActivity { ...@@ -41,6 +43,17 @@ public class MainActivity extends AppCompatActivity {
} }
}); });
// ── File creator (export) ─────────────────────────────────────────────────
private final ActivityResultLauncher<Intent> fileCreateLauncher =
registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
if (uri != null) startExport(uri);
}
});
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
...@@ -68,68 +81,106 @@ public class MainActivity extends AppCompatActivity { ...@@ -68,68 +81,106 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
public boolean onOptionsItemSelected(MenuItem item) { public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.action_add_vocabulary) { int id = item.getItemId();
if (id == R.id.action_add_vocabulary) {
openFilePicker(); openFilePicker();
return true; return true;
} else if (id == R.id.action_save_data) {
openFileSaver();
return true;
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
// ── File picker ─────────────────────────────────────────────────────────── // ── Import ────────────────────────────────────────────────────────────────
private void openFilePicker() { private void openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); // text/csv not always recognised intent.setType("*/*");
intent.putExtra(Intent.EXTRA_MIME_TYPES, intent.putExtra(Intent.EXTRA_MIME_TYPES,
new String[]{"text/csv", "text/comma-separated-values", "text/plain"}); new String[]{"text/csv", "text/comma-separated-values", "text/plain"});
// Pre-select the Downloads folder
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
Uri downloadsUri = android.provider.DocumentsContract.buildRootUri(
"com.android.externalstorage.documents", "primary");
intent.putExtra(android.provider.DocumentsContract.EXTRA_INITIAL_URI, downloadsUri);
}
filePickerLauncher.launch(intent); filePickerLauncher.launch(intent);
} }
// ── Import ────────────────────────────────────────────────────────────────
private void startImport(Uri uri) { private void startImport(Uri uri) {
// Show a non-cancelable progress dialog while importing ProgressBar pb = new ProgressBar(this);
ProgressBar progressBar = new ProgressBar(this); pb.setPadding(64, 64, 64, 64);
progressBar.setPadding(64, 64, 64, 64); AlertDialog dialog = new AlertDialog.Builder(this)
AlertDialog progressDialog = new AlertDialog.Builder(this)
.setTitle("Importing vocabulary…") .setTitle("Importing vocabulary…")
.setView(progressBar) .setView(pb)
.setCancelable(false) .setCancelable(false)
.create(); .create();
progressDialog.show(); dialog.show();
CsvImporter importer = new CsvImporter(this);
exec.execute(() -> { exec.execute(() -> {
CsvImporter.Result result = importer.importFromUri(uri); CsvImporter.Result result = new CsvImporter(this).importFromUri(uri);
runOnUiThread(() -> { runOnUiThread(() -> {
progressDialog.dismiss(); dialog.dismiss();
showResult(result); showImportResult(result);
}); });
}); });
} }
private void showResult(CsvImporter.Result result) { private void showImportResult(CsvImporter.Result r) {
if (result.errorMessage != null) { if (r.errorMessage != null) {
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle("Import failed") .setTitle("Import failed")
.setMessage(result.errorMessage) .setMessage(r.errorMessage)
.setPositiveButton("OK", null)
.show();
} else {
String msg = r.cardsInserted + " word(s) imported";
if (r.triesRestored > 0) msg += ", " + r.triesRestored + " try record(s) restored";
if (r.rowsSkipped > 0) msg += "\n" + r.rowsSkipped + " row(s) skipped";
msg += ".";
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
}
}
// ── Export ────────────────────────────────────────────────────────────────
private void openFileSaver() {
String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
.format(new Date());
String filename = "vocoach_" + timestamp + ".csv";
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("text/csv");
intent.putExtra(Intent.EXTRA_TITLE, filename);
fileCreateLauncher.launch(intent);
}
private void startExport(Uri uri) {
ProgressBar pb = new ProgressBar(this);
pb.setPadding(64, 64, 64, 64);
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("Saving data…")
.setView(pb)
.setCancelable(false)
.create();
dialog.show();
exec.execute(() -> {
CsvExporter.Result result = new CsvExporter(this).exportToUri(uri);
runOnUiThread(() -> {
dialog.dismiss();
showExportResult(result);
});
});
}
private void showExportResult(CsvExporter.Result r) {
if (r.errorMessage != null) {
new AlertDialog.Builder(this)
.setTitle("Save failed")
.setMessage(r.errorMessage)
.setPositiveButton("OK", null) .setPositiveButton("OK", null)
.show(); .show();
} else { } else {
String msg = result.cardsInserted + " word(s) imported."; String msg = r.cardsExported + " word(s) and " +
if (result.rowsSkipped > 0) r.triesExported + " try record(s) saved.";
msg += "\n" + result.rowsSkipped + " row(s) skipped due to errors.";
Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
} }
} }
......
...@@ -12,6 +12,16 @@ public interface LeitnerStateDao { ...@@ -12,6 +12,16 @@ public interface LeitnerStateDao {
@Insert long insert(LeitnerState state); @Insert long insert(LeitnerState state);
@Update void update(LeitnerState state); @Update void update(LeitnerState state);
@Query("SELECT * FROM leitner_state ORDER BY card_id ASC")
List<LeitnerState> getAllSync();
/** Used during extended CSV import to locate a leitner_state by card identity + direction. */
@Query("SELECT ls.* FROM leitner_state ls " +
"JOIN card c ON c.id = ls.card_id " +
"WHERE c.french = :french AND c.native_lang = :nativeLang " +
"AND ls.direction = :direction LIMIT 1")
LeitnerState getByFrenchNativeDirectionSync(String french, String nativeLang, String direction);
@Query("SELECT * FROM leitner_state WHERE id = :id LIMIT 1") @Query("SELECT * FROM leitner_state WHERE id = :id LIMIT 1")
LeitnerState getByIdSync(int id); LeitnerState getByIdSync(int id);
......
...@@ -13,6 +13,10 @@ public interface TryDao { ...@@ -13,6 +13,10 @@ public interface TryDao {
@Query("SELECT * FROM try_record WHERE leitner_state_id = :stateId ORDER BY responded_at ASC") @Query("SELECT * FROM try_record WHERE leitner_state_id = :stateId ORDER BY responded_at ASC")
List<TryRecord> getForStateSync(int stateId); List<TryRecord> getForStateSync(int stateId);
/** All try records ordered by time — used during full export. */
@Query("SELECT * FROM try_record ORDER BY responded_at ASC")
List<TryRecord> getAllSync();
/** Returns leitner_state_ids attempted today (epoch seconds window). */ /** Returns leitner_state_ids attempted today (epoch seconds window). */
@Query("SELECT DISTINCT leitner_state_id FROM try_record " + @Query("SELECT DISTINCT leitner_state_id FROM try_record " +
"WHERE direction = :direction AND responded_at >= :dayStart") "WHERE direction = :direction AND responded_at >= :dayStart")
......
package dev.lueckemeyer.vocoach.importer;
import android.content.Context;
import android.net.Uri;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import dev.lueckemeyer.vocoach.db.VocabDatabase;
import dev.lueckemeyer.vocoach.db.entity.*;
/**
* Exports the full database to a CSV file that can be re-imported by
* CsvImporter (which ignores the extra columns) or by the standalone
* command-line importer.
*
* Format
* ──────
* The file has two sections separated by a blank line:
*
* Section 1 — CARD rows (row_type = "card")
* All 26 standard import columns + leitner_box_fr + leitner_box_native
*
* Section 2 — TRY rows (row_type = "try")
* card_french, card_native_lang, leitner_state_id,
* direction, correct (true/false), responded_at (epoch seconds)
*
* The standard importer ignores columns beyond column 25 and ignores any
* rows whose word_type it does not recognise, so "card" and "try" in column 0
* cause those rows to be silently skipped on a plain re-import — only the
* card data (columns 1-25) is used. The app's own CsvImporter additionally
* processes the extra columns to restore Leitner state and try history.
*/
public class CsvExporter {
// ── Result ────────────────────────────────────────────────────────────────
public static class Result {
public final int cardsExported;
public final int triesExported;
public final String errorMessage; // null = success
Result(int cards, int tries, String error) {
cardsExported = cards;
triesExported = tries;
errorMessage = error;
}
}
private final VocabDatabase db;
private final Context context;
public CsvExporter(Context context) {
this.context = context.getApplicationContext();
this.db = VocabDatabase.getInstance(this.context);
}
// ── Public entry point ────────────────────────────────────────────────────
/** Call from a background thread. Writes the CSV to the given URI. */
public Result exportToUri(Uri uri) {
try (OutputStream os = context.getContentResolver().openOutputStream(uri, "wt");
BufferedWriter w = new BufferedWriter(
new OutputStreamWriter(os, StandardCharsets.UTF_8))) {
// ── Build lookup maps ─────────────────────────────────────────────
// book id → Book
Map<Integer, Book> books = new HashMap<>();
for (Book b : db.bookDao().getAllSync()) books.put(b.id, b);
// unit id → Unit
Map<Integer, Unit> units = new HashMap<>();
for (Book b : books.values())
for (Unit u : db.unitDao().getByBookSync(b.id)) units.put(u.id, u);
// volet id → Volet
Map<Integer, Volet> volets = new HashMap<>();
for (Volet v : db.voletDao().getAllSync()) volets.put(v.id, v);
// card id → Card
Map<Integer, Card> cards = new HashMap<>();
for (Volet v : volets.values())
for (Card c : db.cardDao().getByVoletSync(v.id)) cards.put(c.id, c);
// card id → LeitnerState per direction
Map<Integer, LeitnerState> lsFr = new HashMap<>(); // fr_to_native
Map<Integer, LeitnerState> lsNat = new HashMap<>(); // native_to_fr
for (LeitnerState ls : db.leitnerStateDao().getAllSync()) {
if ("fr_to_native".equals(ls.direction)) lsFr.put(ls.cardId, ls);
else lsNat.put(ls.cardId, ls);
}
// leitner_state id → card id (for try export)
Map<Integer, Integer> lsIdToCardId = new HashMap<>();
for (LeitnerState ls : db.leitnerStateDao().getAllSync())
lsIdToCardId.put(ls.id, ls.cardId);
// ── Section 1: card rows ──────────────────────────────────────────
w.write(cardHeader());
w.newLine();
// Order: book asc, unit position asc, volet position asc, card id asc
List<Card> orderedCards = orderedCards(books, units, volets, cards);
int cardCount = 0;
for (Card card : orderedCards) {
Volet volet = volets.get(card.voletId);
Unit unit = units.get(volet.unitId);
Book book = books.get(unit.bookId);
NounDetail nd = db.detailDao().getNounSync(card.id);
VerbDetail vd = db.detailDao().getVerbSync(card.id);
AdjectiveDetail ad = db.detailDao().getAdjectiveSync(card.id);
AdverbDetail rd = db.detailDao().getAdverbSync(card.id);
LeitnerState lsFrState = lsFr.getOrDefault(card.id, null);
LeitnerState lsNatState = lsNat.getOrDefault(card.id, null);
int boxFr = lsFrState != null ? lsFrState.boxNumber : 1;
int boxNat = lsNatState != null ? lsNatState.boxNumber : 1;
w.write(cardRow(card, book, unit, volet, nd, vd, ad, rd, boxFr, boxNat));
w.newLine();
cardCount++;
}
// ── Section 2: try rows ───────────────────────────────────────────
w.newLine();
w.write(tryHeader());
w.newLine();
List<TryRecord> tries = db.tryDao().getAllSync();
int tryCount = 0;
for (TryRecord tr : tries) {
Integer cardId = lsIdToCardId.get(tr.leitnerStateId);
if (cardId == null) continue;
Card card = cards.get(cardId);
if (card == null) continue;
w.write(tryRow(card, tr));
w.newLine();
tryCount++;
}
w.flush();
return new Result(cardCount, tryCount, null);
} catch (Exception e) {
return new Result(0, 0, "Export failed: " + e.getMessage());
}
}
// ── CSV headers ───────────────────────────────────────────────────────────
private static String cardHeader() {
return "row_type,word_type,french,native_lang,phonetic,example_sentence,notes," +
"book_title,book_description,unit_position,unit_title," +
"volet_position,volet_title," +
"gender,plural_form,is_proper," +
"infinitive,verb_group,participe_passe,participe_present," +
"auxiliary,is_reflexive,is_irregular," +
"feminine_form,plural_form_adj,feminine_plural_form,derived_from," +
"leitner_box_fr,leitner_box_native";
}
private static String tryHeader() {
return "row_type,card_french,card_native_lang," +
"leitner_state_id,direction,correct,responded_at";
}
// ── Row builders ──────────────────────────────────────────────────────────
private static String cardRow(Card card, Book book, Unit unit, Volet volet,
NounDetail nd, VerbDetail vd,
AdjectiveDetail ad, AdverbDetail rd,
int boxFr, int boxNat) {
return csv(
"card",
s(card.wordType),
s(card.french),
s(card.nativeLang),
s(card.phonetic),
s(card.exampleSentence),
s(card.notes),
s(book.title),
s(book.description),
String.valueOf(unit.position),
s(unit.title),
String.valueOf(volet.position),
s(volet.title),
// noun
nd != null ? s(nd.gender) : "",
nd != null ? s(nd.pluralForm) : "",
nd != null ? b(nd.isProper) : "",
// verb
vd != null ? s(vd.infinitive) : "",
vd != null ? s(vd.verbGroup) : "",
vd != null ? s(vd.participePasse) : "",
vd != null ? s(vd.participePresent) : "",
vd != null ? s(vd.auxiliary) : "",
vd != null ? b(vd.isReflexive) : "",
vd != null ? b(vd.isIrregular) : "",
// adjective
ad != null ? s(ad.feminineForm) : "",
ad != null ? s(ad.pluralForm) : "",
ad != null ? s(ad.femininePluralForm) : "",
// adverb
rd != null ? s(rd.derivedFrom) : "",
// leitner state
String.valueOf(boxFr),
String.valueOf(boxNat)
);
}
private static String tryRow(Card card, TryRecord tr) {
return csv(
"try",
s(card.french),
s(card.nativeLang),
String.valueOf(tr.leitnerStateId),
s(tr.direction),
b(tr.correct),
String.valueOf(tr.respondedAt)
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/** Null-safe string — empty string if null. */
private static String s(String v) { return v != null ? v : ""; }
/** Boolean to "true"/"false". */
private static String b(boolean v) { return v ? "true" : "false"; }
/**
* Joins fields as a CSV line. Fields containing commas, quotes or
* newlines are wrapped in double-quotes; internal double-quotes are escaped
* by doubling them.
*/
private static String csv(String... fields) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < fields.length; i++) {
if (i > 0) sb.append(',');
String f = fields[i];
if (f.contains(",") || f.contains("\"") || f.contains("\n")) {
sb.append('"').append(f.replace("\"", "\"\"")).append('"');
} else {
sb.append(f);
}
}
return sb.toString();
}
/** Returns cards ordered by book id, unit position, volet position, card id. */
private static List<Card> orderedCards(Map<Integer, Book> books,
Map<Integer, Unit> units,
Map<Integer, Volet> volets,
Map<Integer, Card> cards) {
List<Card> list = new ArrayList<>(cards.values());
list.sort((a, b) -> {
Volet va = volets.get(a.voletId), vb = volets.get(b.voletId);
Unit ua = units.get(va.unitId), ub = units.get(vb.unitId);
Book ba = books.get(ua.bookId), bb = books.get(ub.bookId);
if (ba.id != bb.id) return Integer.compare(ba.id, bb.id);
if (ua.position != ub.position) return Integer.compare(ua.position, ub.position);
if (va.position != vb.position) return Integer.compare(va.position, vb.position);
return Integer.compare(a.id, b.id);
});
return list;
}
}
...@@ -12,16 +12,26 @@ import dev.lueckemeyer.vocoach.db.dao.*; ...@@ -12,16 +12,26 @@ import dev.lueckemeyer.vocoach.db.dao.*;
import dev.lueckemeyer.vocoach.db.entity.*; import dev.lueckemeyer.vocoach.db.entity.*;
/** /**
* Imports vocabulary from a CSV file (same format as the standalone importer) * Imports vocabulary from a CSV file into the Room database.
* into the Room database using Room DAOs. Runs entirely on a background thread.
* *
* Append-only: existing data is never deleted. Books, units and volets are * Supports two CSV layouts:
* deduplicated by title/position so re-importing an already-loaded volet only *
* adds genuinely new cards. * 1. Legacy 26-column format (col 0 = word_type … col 25 = derived_from)
* — produced by the standalone command-line importer and the OCR export.
*
* 2. Extended format produced by CsvExporter (col 0 = row_type):
* - "card" rows (29 cols): all 26 standard fields + leitner_box_fr +
* leitner_box_native — Leitner box numbers are restored.
* - "try" rows (7 cols): card_french, card_native_lang,
* leitner_state_id, direction, correct, responded_at
* — Try history is fully restored.
*
* Append-only: existing data is never deleted. Books/units/volets are
* deduplicated by title/position.
*/ */
public class CsvImporter { public class CsvImporter {
// ── Column indices — must match the CSV header ──────────────────────────── // ── Legacy layout column indices (col 0 = word_type) ─────────────────────
private static final int C_WORD_TYPE = 0; private static final int C_WORD_TYPE = 0;
private static final int C_FRENCH = 1; private static final int C_FRENCH = 1;
private static final int C_NATIVE_LANG = 2; private static final int C_NATIVE_LANG = 2;
...@@ -48,28 +58,77 @@ public class CsvImporter { ...@@ -48,28 +58,77 @@ public class CsvImporter {
private static final int C_PLURAL_FORM_ADJ = 23; private static final int C_PLURAL_FORM_ADJ = 23;
private static final int C_FEM_PLURAL_FORM = 24; private static final int C_FEM_PLURAL_FORM = 24;
private static final int C_DERIVED_FROM = 25; private static final int C_DERIVED_FROM = 25;
private static final int EXPECTED_COLUMNS = 26; private static final int LEGACY_COLUMNS = 26;
// ── Result handed back to the UI ────────────────────────────────────────── // ── Extended layout column indices (col 0 = row_type) ────────────────────
// Card rows: cols 1-26 = same as legacy, cols 27-28 = Leitner boxes
private static final int E_ROW_TYPE = 0;
private static final int E_WORD_TYPE = 1;
private static final int E_FRENCH = 2;
private static final int E_NATIVE_LANG = 3;
private static final int E_PHONETIC = 4;
private static final int E_EXAMPLE_SENTENCE = 5;
private static final int E_NOTES = 6;
private static final int E_BOOK_TITLE = 7;
private static final int E_BOOK_DESC = 8;
private static final int E_UNIT_POSITION = 9;
private static final int E_UNIT_TITLE = 10;
private static final int E_VOLET_POSITION = 11;
private static final int E_VOLET_TITLE = 12;
private static final int E_GENDER = 13;
private static final int E_PLURAL_FORM = 14;
private static final int E_IS_PROPER = 15;
private static final int E_INFINITIVE = 16;
private static final int E_VERB_GROUP = 17;
private static final int E_PARTICIPE_PASSE = 18;
private static final int E_PARTICIPE_PRESENT = 19;
private static final int E_AUXILIARY = 20;
private static final int E_IS_REFLEXIVE = 21;
private static final int E_IS_IRREGULAR = 22;
private static final int E_FEMININE_FORM = 23;
private static final int E_PLURAL_FORM_ADJ = 24;
private static final int E_FEM_PLURAL_FORM = 25;
private static final int E_DERIVED_FROM = 26;
private static final int E_LEITNER_BOX_FR = 27;
private static final int E_LEITNER_BOX_NAT = 28;
// Try rows: col 0 = "try", col 1 = french, col 2 = native,
// col 3 = leitner_state_id, col 4 = direction,
// col 5 = correct, col 6 = responded_at
private static final int T_FRENCH = 1;
private static final int T_NATIVE = 2;
private static final int T_LS_ID = 3;
private static final int T_DIRECTION = 4;
private static final int T_CORRECT = 5;
private static final int T_RESPONDED_AT = 6;
// ── Result ────────────────────────────────────────────────────────────────
public static class Result { public static class Result {
public final int cardsInserted; public final int cardsInserted;
public final int rowsSkipped; public final int triesRestored;
public final String errorMessage; // null = success public final int rowsSkipped;
public final String errorMessage;
Result(int inserted, int skipped, String error) {
cardsInserted = inserted; Result(int cards, int tries, int skipped, String error) {
cardsInserted = cards;
triesRestored = tries;
rowsSkipped = skipped; rowsSkipped = skipped;
errorMessage = error; errorMessage = error;
} }
} }
// ── In-memory id caches (deduplicate parents within one import run) ──────── // ── In-memory caches ──────────────────────────────────────────────────────
private final Map<String, Long> bookCache = new LinkedHashMap<>(); private final Map<String, Long> bookCache = new LinkedHashMap<>();
private final Map<String, Long> unitCache = new LinkedHashMap<>(); private final Map<String, Long> unitCache = new LinkedHashMap<>();
private final Map<String, Long> voletCache = new LinkedHashMap<>(); private final Map<String, Long> voletCache = new LinkedHashMap<>();
/**
* Maps the old leitner_state_id (from the exported file) to the newly
* inserted leitner_state id so that try rows can be restored correctly.
*/
private final Map<Integer, Long> lsIdRemap = new HashMap<>();
private final VocabDatabase db; private final VocabDatabase db;
private final Context context; private final Context context;
private boolean extendedFormat = false;
public CsvImporter(Context context) { public CsvImporter(Context context) {
this.context = context.getApplicationContext(); this.context = context.getApplicationContext();
...@@ -78,106 +137,153 @@ public class CsvImporter { ...@@ -78,106 +137,153 @@ public class CsvImporter {
// ── Public entry point ──────────────────────────────────────────────────── // ── Public entry point ────────────────────────────────────────────────────
/** Call from a background thread. Opens the URI, parses, inserts. */
public Result importFromUri(Uri uri) { public Result importFromUri(Uri uri) {
// Pre-load existing parents so we deduplicate across sessions
loadExistingParents(); loadExistingParents();
int cardCount = 0, errorCount = 0; int cardCount = 0, tryCount = 0, errorCount = 0;
try (InputStream is = context.getContentResolver().openInputStream(uri); try (InputStream is = context.getContentResolver().openInputStream(uri);
BufferedReader br = new BufferedReader( BufferedReader br = new BufferedReader(
new InputStreamReader(is, StandardCharsets.UTF_8))) { new InputStreamReader(is, StandardCharsets.UTF_8))) {
String header = br.readLine(); String header = br.readLine();
if (header == null) return new Result(0, 0, "CSV file is empty."); if (header == null) return new Result(0, 0, 0, "CSV file is empty.");
// Detect format by checking first column name
String[] headerCols = parseCsvLine(header); String[] headerCols = parseCsvLine(header);
if (headerCols.length < EXPECTED_COLUMNS) { extendedFormat = headerCols.length > 0 &&
return new Result(0, 0, "row_type".equalsIgnoreCase(headerCols[0].trim());
if (!extendedFormat && headerCols.length < LEGACY_COLUMNS) {
return new Result(0, 0, 0,
"CSV has only " + headerCols.length + "CSV has only " + headerCols.length +
" columns; expected " + EXPECTED_COLUMNS + "."); " columns; expected " + LEGACY_COLUMNS + ".");
} }
String line; String line;
int lineNum = 1;
while ((line = br.readLine()) != null) { while ((line = br.readLine()) != null) {
lineNum++;
line = line.trim(); line = line.trim();
if (line.isEmpty()) continue; if (line.isEmpty()) continue;
try { try {
String[] cols = parseCsvLine(line); String[] cols = parseCsvLine(line);
if (cols.length < EXPECTED_COLUMNS) {
cols = Arrays.copyOf(cols, EXPECTED_COLUMNS); if (extendedFormat) {
for (int i = 0; i < cols.length; i++) if (cols.length == 0) continue;
if (cols[i] == null) cols[i] = ""; String rowType = cols[E_ROW_TYPE].trim().toLowerCase();
if ("card".equals(rowType)) {
importCardExtended(pad(cols, 29));
cardCount++;
} else if ("try".equals(rowType)) {
importTryRow(pad(cols, 7));
tryCount++;
}
// skip unknown row types silently
} else {
importCardLegacy(pad(cols, LEGACY_COLUMNS));
cardCount++;
} }
importRow(cols);
cardCount++;
} catch (Exception e) { } catch (Exception e) {
errorCount++; errorCount++;
} }
} }
} catch (Exception e) { } catch (Exception e) {
return new Result(cardCount, errorCount, return new Result(cardCount, tryCount, errorCount,
"Import failed: " + e.getMessage()); "Import failed: " + e.getMessage());
} }
return new Result(cardCount, errorCount, null); return new Result(cardCount, tryCount, errorCount, null);
} }
// ── Load existing parents into cache ────────────────────────────────────── // ── Load existing parents ─────────────────────────────────────────────────
private void loadExistingParents() { private void loadExistingParents() {
// Books keyed by title for (Book b : db.bookDao().getAllSync())
for (Book b : db.bookDao().getAllSync()) {
bookCache.put(b.title, (long) b.id); bookCache.put(b.title, (long) b.id);
} for (Book b : db.bookDao().getAllSync())
// Units keyed by book_id:position for (Unit u : db.unitDao().getByBookSync(b.id))
for (Book b : db.bookDao().getAllSync()) {
for (Unit u : db.unitDao().getByBookSync(b.id)) {
unitCache.put(b.id + ":" + u.position, (long) u.id); unitCache.put(b.id + ":" + u.position, (long) u.id);
} for (Volet v : db.voletDao().getAllSync())
}
// Volets keyed by unit_id:position
for (Volet v : db.voletDao().getAllSync()) {
voletCache.put(v.unitId + ":" + v.position, (long) v.id); voletCache.put(v.unitId + ":" + v.position, (long) v.id);
}
} }
// ── Row import ──────────────────────────────────────────────────────────── // ── Card import — legacy format ───────────────────────────────────────────
private void importRow(String[] c) { private void importCardLegacy(String[] c) {
long now = System.currentTimeMillis() / 1000L; long now = System.currentTimeMillis() / 1000L;
long bookId = getOrCreateBook(c[C_BOOK_TITLE], c[C_BOOK_DESC]); long bookId = getOrCreateBook(c[C_BOOK_TITLE], c[C_BOOK_DESC]);
long unitId = getOrCreateUnit((int) bookId, long unitId = getOrCreateUnit((int) bookId, parseInt(c[C_UNIT_POSITION], 1), c[C_UNIT_TITLE]);
parseInt(c[C_UNIT_POSITION], 1), c[C_UNIT_TITLE]); long voletId = getOrCreateVolet((int) unitId, parseInt(c[C_VOLET_POSITION], 1), c[C_VOLET_TITLE]);
long voletId = getOrCreateVolet((int) unitId, long cardId = insertCard((int) voletId, c[C_WORD_TYPE], c[C_FRENCH],
parseInt(c[C_VOLET_POSITION], 1), c[C_VOLET_TITLE]); c[C_NATIVE_LANG], c[C_PHONETIC], c[C_EXAMPLE_SENTENCE],
long cardId = insertCard((int) voletId, c, now); c[C_NOTES], now);
insertDetails(cardId, c[C_WORD_TYPE], c);
insertLeitnerState((int) cardId, "fr_to_native", 1, now);
insertLeitnerState((int) cardId, "native_to_fr", 1, now);
}
String type = c[C_WORD_TYPE].trim().toLowerCase(); // ── Card import — extended format ─────────────────────────────────────────
switch (type) {
case "noun": insertNounDetail((int) cardId, c); break;
case "verb": insertVerbDetail((int) cardId, c); break;
case "adjective": insertAdjectiveDetail((int) cardId, c); break;
case "adverb": insertAdverbDetail((int) cardId, c); break;
}
insertLeitnerState((int) cardId, "fr_to_native", now); private void importCardExtended(String[] c) {
insertLeitnerState((int) cardId, "native_to_fr", now); long now = System.currentTimeMillis() / 1000L;
long bookId = getOrCreateBook(c[E_BOOK_TITLE], c[E_BOOK_DESC]);
long unitId = getOrCreateUnit((int) bookId, parseInt(c[E_UNIT_POSITION], 1), c[E_UNIT_TITLE]);
long voletId = getOrCreateVolet((int) unitId, parseInt(c[E_VOLET_POSITION], 1), c[E_VOLET_TITLE]);
long cardId = insertCard((int) voletId, c[E_WORD_TYPE], c[E_FRENCH],
c[E_NATIVE_LANG], c[E_PHONETIC], c[E_EXAMPLE_SENTENCE],
c[E_NOTES], now);
// Remap C_* columns by shifting (col offset = E_* - 1)
String[] legacyCols = shiftToLegacy(c);
insertDetails(cardId, c[E_WORD_TYPE], legacyCols);
int boxFr = parseInt(c[E_LEITNER_BOX_FR], 1);
int boxNat = parseInt(c[E_LEITNER_BOX_NAT], 1);
long lsFrId = insertLeitnerState((int) cardId, "fr_to_native", boxFr, now);
long lsNatId = insertLeitnerState((int) cardId, "native_to_fr", boxNat, now);
// Store remapping: exported LS ids are unknown here; we remap by card+direction
// The try rows reference the OLD leitner_state_id from the exported DB.
// We store card_id → new LS ids so importTryRow can look them up.
lsIdRemap.put(-(int) cardId, lsFrId); // negative = fr key
lsIdRemap.put((int) cardId, lsNatId); // positive = nat key
} }
// ── Entity upserts ──────────────────────────────────────────────────────── // ── Try import ────────────────────────────────────────────────────────────
private void importTryRow(String[] c) {
// We look up the leitner_state by the direction and card identified by
// french+native_lang strings (the exported leitner_state_id is from the
// old DB and is irrelevant here).
String french = c[T_FRENCH].trim();
String native_ = c[T_NATIVE].trim();
String direction = c[T_DIRECTION].trim();
boolean correct = parseBool(c[T_CORRECT]);
long respondedAt = parseLong(c[T_RESPONDED_AT], 0L);
// Find the matching leitner_state by looking up card by french+native,
// then the state by direction
LeitnerState ls = db.leitnerStateDao().getByFrenchNativeDirectionSync(
french, native_, direction);
if (ls == null) return; // card not found — skip
TryRecord tr = new TryRecord();
tr.leitnerStateId = ls.id;
tr.direction = direction;
tr.correct = correct;
tr.respondedAt = respondedAt;
tr.sessionId = null;
db.tryDao().insert(tr);
}
// ── Entity helpers ────────────────────────────────────────────────────────
private long getOrCreateBook(String title, String desc) { private long getOrCreateBook(String title, String desc) {
String key = title.trim(); String key = title.trim();
if (bookCache.containsKey(key)) return bookCache.get(key); if (bookCache.containsKey(key)) return bookCache.get(key);
Book b = new Book(); Book b = new Book(); b.title = key; b.description = nvl(desc);
b.title = key;
b.description = nvl(desc);
long id = db.bookDao().insert(b); long id = db.bookDao().insert(b);
bookCache.put(key, id); bookCache.put(key, id);
return id; return id;
...@@ -186,10 +292,7 @@ public class CsvImporter { ...@@ -186,10 +292,7 @@ public class CsvImporter {
private long getOrCreateUnit(int bookId, int position, String title) { private long getOrCreateUnit(int bookId, int position, String title) {
String key = bookId + ":" + position; String key = bookId + ":" + position;
if (unitCache.containsKey(key)) return unitCache.get(key); if (unitCache.containsKey(key)) return unitCache.get(key);
Unit u = new Unit(); Unit u = new Unit(); u.bookId = bookId; u.position = position; u.title = title.trim();
u.bookId = bookId;
u.position = position;
u.title = title.trim();
long id = db.unitDao().insert(u); long id = db.unitDao().insert(u);
unitCache.put(key, id); unitCache.put(key, id);
return id; return id;
...@@ -198,74 +301,79 @@ public class CsvImporter { ...@@ -198,74 +301,79 @@ public class CsvImporter {
private long getOrCreateVolet(int unitId, int position, String title) { private long getOrCreateVolet(int unitId, int position, String title) {
String key = unitId + ":" + position; String key = unitId + ":" + position;
if (voletCache.containsKey(key)) return voletCache.get(key); if (voletCache.containsKey(key)) return voletCache.get(key);
Volet v = new Volet(); Volet v = new Volet(); v.unitId = unitId; v.position = position; v.title = title.trim();
v.unitId = unitId;
v.position = position;
v.title = title.trim();
long id = db.voletDao().insert(v); long id = db.voletDao().insert(v);
voletCache.put(key, id); voletCache.put(key, id);
return id; return id;
} }
private long insertCard(int voletId, String[] c, long now) { private long insertCard(int voletId, String wordType, String french,
String nativeLang, String phonetic,
String example, String notes, long now) {
Card card = new Card(); Card card = new Card();
card.voletId = voletId; card.voletId = voletId;
card.french = c[C_FRENCH].trim(); card.french = french.trim();
card.nativeLang = c[C_NATIVE_LANG].trim(); card.nativeLang = nativeLang.trim();
card.phonetic = nvl(c[C_PHONETIC]); card.phonetic = nvl(phonetic);
card.wordType = nvl(c[C_WORD_TYPE]); card.wordType = nvl(wordType);
card.exampleSentence = nvl(c[C_EXAMPLE_SENTENCE]); card.exampleSentence = nvl(example);
card.notes = nvl(c[C_NOTES]); card.notes = nvl(notes);
card.createdAt = now; card.createdAt = now;
return db.cardDao().insert(card); return db.cardDao().insert(card);
} }
private void insertNounDetail(int cardId, String[] c) { private void insertDetails(long cardId, String wordType, String[] c) {
NounDetail d = new NounDetail(); String type = wordType == null ? "" : wordType.trim().toLowerCase();
d.cardId = cardId; switch (type) {
d.gender = nvl(c[C_GENDER]); case "noun": {
d.pluralForm = nvl(c[C_PLURAL_FORM]); NounDetail d = new NounDetail();
d.isProper = parseBool(c[C_IS_PROPER]); d.cardId = (int) cardId;
db.detailDao().insertNoun(d); d.gender = nvl(c[C_GENDER]);
} d.pluralForm = nvl(c[C_PLURAL_FORM]);
d.isProper = parseBool(c[C_IS_PROPER]);
private void insertVerbDetail(int cardId, String[] c) { db.detailDao().insertNoun(d);
VerbDetail d = new VerbDetail(); break;
d.cardId = cardId; }
d.infinitive = nvl(c[C_INFINITIVE]); case "verb": {
d.verbGroup = nvl(c[C_VERB_GROUP]); VerbDetail d = new VerbDetail();
d.participePasse = nvl(c[C_PARTICIPE_PASSE]); d.cardId = (int) cardId;
d.participePresent= nvl(c[C_PARTICIPE_PRESENT]); d.infinitive = nvl(c[C_INFINITIVE]);
d.auxiliary = nvl(c[C_AUXILIARY]); d.verbGroup = nvl(c[C_VERB_GROUP]);
d.isReflexive = parseBool(c[C_IS_REFLEXIVE]); d.participePasse = nvl(c[C_PARTICIPE_PASSE]);
d.isIrregular = parseBool(c[C_IS_IRREGULAR]); d.participePresent= nvl(c[C_PARTICIPE_PRESENT]);
db.detailDao().insertVerb(d); d.auxiliary = nvl(c[C_AUXILIARY]);
} d.isReflexive = parseBool(c[C_IS_REFLEXIVE]);
d.isIrregular = parseBool(c[C_IS_IRREGULAR]);
private void insertAdjectiveDetail(int cardId, String[] c) { db.detailDao().insertVerb(d);
AdjectiveDetail d = new AdjectiveDetail(); break;
d.cardId = cardId; }
d.feminineForm = nvl(c[C_FEMININE_FORM]); case "adjective": {
d.pluralForm = nvl(c[C_PLURAL_FORM_ADJ]); AdjectiveDetail d = new AdjectiveDetail();
d.femininePluralForm = nvl(c[C_FEM_PLURAL_FORM]); d.cardId = (int) cardId;
db.detailDao().insertAdjective(d); d.feminineForm = nvl(c[C_FEMININE_FORM]);
} d.pluralForm = nvl(c[C_PLURAL_FORM_ADJ]);
d.femininePluralForm = nvl(c[C_FEM_PLURAL_FORM]);
private void insertAdverbDetail(int cardId, String[] c) { db.detailDao().insertAdjective(d);
AdverbDetail d = new AdverbDetail(); break;
d.cardId = cardId; }
d.derivedFrom = nvl(c[C_DERIVED_FROM]); case "adverb": {
db.detailDao().insertAdverb(d); AdverbDetail d = new AdverbDetail();
d.cardId = (int) cardId;
d.derivedFrom = nvl(c[C_DERIVED_FROM]);
db.detailDao().insertAdverb(d);
break;
}
}
} }
private void insertLeitnerState(int cardId, String direction, long now) { private long insertLeitnerState(int cardId, String direction, int box, long now) {
LeitnerState ls = new LeitnerState(); LeitnerState ls = new LeitnerState();
ls.cardId = cardId; ls.cardId = cardId;
ls.direction = direction; ls.direction = direction;
ls.boxNumber = 1; ls.boxNumber = box;
ls.nextReviewAt = 0; ls.nextReviewAt = 0;
ls.updatedAt = now; ls.updatedAt = now;
db.leitnerStateDao().insert(ls); return db.leitnerStateDao().insert(ls);
} }
// ── CSV parser ──────────────────────────────────────────────────────────── // ── CSV parser ────────────────────────────────────────────────────────────
...@@ -279,15 +387,10 @@ public class CsvImporter { ...@@ -279,15 +387,10 @@ public class CsvImporter {
if (ch == '"') { if (ch == '"') {
if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') { if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
sb.append('"'); i++; sb.append('"'); i++;
} else { } else { inQuotes = !inQuotes; }
inQuotes = !inQuotes;
}
} else if (ch == ',' && !inQuotes) { } else if (ch == ',' && !inQuotes) {
fields.add(sb.toString()); fields.add(sb.toString()); sb.setLength(0);
sb.setLength(0); } else { sb.append(ch); }
} else {
sb.append(ch);
}
} }
fields.add(sb.toString()); fields.add(sb.toString());
return fields.toArray(new String[0]); return fields.toArray(new String[0]);
...@@ -295,16 +398,35 @@ public class CsvImporter { ...@@ -295,16 +398,35 @@ public class CsvImporter {
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
/** Shift extended columns back to legacy offsets for reuse of insertDetails. */
private static String[] shiftToLegacy(String[] ext) {
// ext[1..26] map to legacy[0..25]
String[] leg = new String[LEGACY_COLUMNS];
for (int i = 0; i < LEGACY_COLUMNS; i++)
leg[i] = i + 1 < ext.length ? ext[i + 1] : "";
return leg;
}
private static String[] pad(String[] cols, int size) {
if (cols.length >= size) return cols;
String[] padded = Arrays.copyOf(cols, size);
for (int i = cols.length; i < size; i++) padded[i] = "";
return padded;
}
private static String nvl(String s) { private static String nvl(String s) {
if (s == null) return null; if (s == null) return null;
String t = s.trim(); String t = s.trim(); return t.isEmpty() ? null : t;
return t.isEmpty() ? null : t;
} }
private static int parseInt(String s, int fallback) { private static int parseInt(String s, int fallback) {
try { return Integer.parseInt(s.trim()); } catch (Exception e) { return fallback; } try { return Integer.parseInt(s.trim()); } catch (Exception e) { return fallback; }
} }
private static long parseLong(String s, long fallback) {
try { return Long.parseLong(s.trim()); } catch (Exception e) { return fallback; }
}
private static boolean parseBool(String s) { private static boolean parseBool(String s) {
if (s == null) return false; if (s == null) return false;
return s.trim().equalsIgnoreCase("true") || s.trim().equals("1"); return s.trim().equalsIgnoreCase("true") || s.trim().equals("1");
......
...@@ -8,4 +8,10 @@ ...@@ -8,4 +8,10 @@
android:icon="@android:drawable/ic_menu_add" android:icon="@android:drawable/ic_menu_add"
app:showAsAction="never"/> app:showAsAction="never"/>
<item
android:id="@+id/action_save_data"
android:title="@string/action_save_data"
android:icon="@android:drawable/ic_menu_save"
app:showAsAction="never"/>
</menu> </menu>
...@@ -2,4 +2,5 @@ ...@@ -2,4 +2,5 @@
<resources> <resources>
<string name="app_name">VoCoach</string> <string name="app_name">VoCoach</string>
<string name="action_add_vocabulary">Add vocabulary</string> <string name="action_add_vocabulary">Add vocabulary</string>
<string name="action_save_data">Save data</string>
</resources> </resources>
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment