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

claude added export functionality, 2 iterations

parent bc2dc6fb
......@@ -5,6 +5,9 @@
<!-- Needed on API 26–28 to read files from Downloads via file picker -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
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
android:name=".VoCoachApp"
......
......@@ -4,10 +4,8 @@ import android.app.AlertDialog;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;
......@@ -19,10 +17,14 @@ import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration;
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.Executors;
import dev.lueckemeyer.vocoach.databinding.ActivityMainBinding;
import dev.lueckemeyer.vocoach.importer.CsvExporter;
import dev.lueckemeyer.vocoach.importer.CsvImporter;
public class MainActivity extends AppCompatActivity {
......@@ -30,7 +32,7 @@ public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private final ExecutorService exec = Executors.newSingleThreadExecutor();
// ── File picker launcher ──────────────────────────────────────────────────
// ── File picker (import) ──────────────────────────────────────────────────
private final ActivityResultLauncher<Intent> filePickerLauncher =
registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
......@@ -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
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
......@@ -68,68 +81,106 @@ public class MainActivity extends AppCompatActivity {
@Override
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();
return true;
} else if (id == R.id.action_save_data) {
openFileSaver();
return true;
}
return super.onOptionsItemSelected(item);
}
// ── File picker ───────────────────────────────────────────────────────────
// ── Import ────────────────────────────────────────────────────────────────
private void openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); // text/csv not always recognised
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_MIME_TYPES,
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);
}
// ── Import ────────────────────────────────────────────────────────────────
private void startImport(Uri uri) {
// Show a non-cancelable progress dialog while importing
ProgressBar progressBar = new ProgressBar(this);
progressBar.setPadding(64, 64, 64, 64);
AlertDialog progressDialog = new AlertDialog.Builder(this)
ProgressBar pb = new ProgressBar(this);
pb.setPadding(64, 64, 64, 64);
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("Importing vocabulary…")
.setView(progressBar)
.setView(pb)
.setCancelable(false)
.create();
progressDialog.show();
CsvImporter importer = new CsvImporter(this);
dialog.show();
exec.execute(() -> {
CsvImporter.Result result = importer.importFromUri(uri);
CsvImporter.Result result = new CsvImporter(this).importFromUri(uri);
runOnUiThread(() -> {
progressDialog.dismiss();
showResult(result);
dialog.dismiss();
showImportResult(result);
});
});
}
private void showResult(CsvImporter.Result result) {
if (result.errorMessage != null) {
private void showImportResult(CsvImporter.Result r) {
if (r.errorMessage != null) {
new AlertDialog.Builder(this)
.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)
.show();
} else {
String msg = result.cardsInserted + " word(s) imported.";
if (result.rowsSkipped > 0)
msg += "\n" + result.rowsSkipped + " row(s) skipped due to errors.";
String msg = r.cardsExported + " word(s) and " +
r.triesExported + " try record(s) saved.";
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
}
}
......
......@@ -12,6 +12,16 @@ public interface LeitnerStateDao {
@Insert long insert(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")
LeitnerState getByIdSync(int id);
......
......@@ -13,6 +13,10 @@ public interface TryDao {
@Query("SELECT * FROM try_record WHERE leitner_state_id = :stateId ORDER BY responded_at ASC")
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). */
@Query("SELECT DISTINCT leitner_state_id FROM try_record " +
"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.*;
import dev.lueckemeyer.vocoach.db.entity.*;
/**
* Imports vocabulary from a CSV file (same format as the standalone importer)
* into the Room database using Room DAOs. Runs entirely on a background thread.
* Imports vocabulary from a CSV file into the Room database.
*
* Append-only: existing data is never deleted. Books, units and volets are
* deduplicated by title/position so re-importing an already-loaded volet only
* adds genuinely new cards.
* Supports two CSV layouts:
*
* 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 {
// ── 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_FRENCH = 1;
private static final int C_NATIVE_LANG = 2;
......@@ -48,28 +58,77 @@ public class CsvImporter {
private static final int C_PLURAL_FORM_ADJ = 23;
private static final int C_FEM_PLURAL_FORM = 24;
private static final int C_DERIVED_FROM = 25;
private static final int EXPECTED_COLUMNS = 26;
// ── Result handed back to the UI ──────────────────────────────────────────
private static final int LEGACY_COLUMNS = 26;
// ── 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 final int cardsInserted;
public final int rowsSkipped;
public final String errorMessage; // null = success
Result(int inserted, int skipped, String error) {
cardsInserted = inserted;
public final int cardsInserted;
public final int triesRestored;
public final int rowsSkipped;
public final String errorMessage;
Result(int cards, int tries, int skipped, String error) {
cardsInserted = cards;
triesRestored = tries;
rowsSkipped = skipped;
errorMessage = error;
}
}
// ── In-memory id caches (deduplicate parents within one import run) ────────
private final Map<String, Long> bookCache = new LinkedHashMap<>();
private final Map<String, Long> unitCache = new LinkedHashMap<>();
private final Map<String, Long> voletCache = new LinkedHashMap<>();
// ── In-memory caches ──────────────────────────────────────────────────────
private final Map<String, Long> bookCache = new LinkedHashMap<>();
private final Map<String, Long> unitCache = 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 Context context;
private boolean extendedFormat = false;
public CsvImporter(Context context) {
this.context = context.getApplicationContext();
......@@ -78,106 +137,153 @@ public class CsvImporter {
// ── Public entry point ────────────────────────────────────────────────────
/** Call from a background thread. Opens the URI, parses, inserts. */
public Result importFromUri(Uri uri) {
// Pre-load existing parents so we deduplicate across sessions
loadExistingParents();
int cardCount = 0, errorCount = 0;
int cardCount = 0, tryCount = 0, errorCount = 0;
try (InputStream is = context.getContentResolver().openInputStream(uri);
BufferedReader br = new BufferedReader(
new InputStreamReader(is, StandardCharsets.UTF_8))) {
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);
if (headerCols.length < EXPECTED_COLUMNS) {
return new Result(0, 0,
extendedFormat = headerCols.length > 0 &&
"row_type".equalsIgnoreCase(headerCols[0].trim());
if (!extendedFormat && headerCols.length < LEGACY_COLUMNS) {
return new Result(0, 0, 0,
"CSV has only " + headerCols.length +
" columns; expected " + EXPECTED_COLUMNS + ".");
" columns; expected " + LEGACY_COLUMNS + ".");
}
String line;
int lineNum = 1;
while ((line = br.readLine()) != null) {
lineNum++;
line = line.trim();
if (line.isEmpty()) continue;
try {
String[] cols = parseCsvLine(line);
if (cols.length < EXPECTED_COLUMNS) {
cols = Arrays.copyOf(cols, EXPECTED_COLUMNS);
for (int i = 0; i < cols.length; i++)
if (cols[i] == null) cols[i] = "";
if (extendedFormat) {
if (cols.length == 0) continue;
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) {
errorCount++;
}
}
} catch (Exception e) {
return new Result(cardCount, errorCount,
return new Result(cardCount, tryCount, errorCount,
"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() {
// Books keyed by title
for (Book b : db.bookDao().getAllSync()) {
for (Book b : db.bookDao().getAllSync())
bookCache.put(b.title, (long) b.id);
}
// Units keyed by book_id:position
for (Book b : db.bookDao().getAllSync()) {
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);
}
}
// Volets keyed by unit_id:position
for (Volet v : db.voletDao().getAllSync()) {
for (Volet v : db.voletDao().getAllSync())
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 bookId = getOrCreateBook(c[C_BOOK_TITLE], c[C_BOOK_DESC]);
long unitId = getOrCreateUnit((int) bookId,
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 cardId = insertCard((int) voletId, c, now);
long unitId = getOrCreateUnit((int) bookId, 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 cardId = insertCard((int) voletId, c[C_WORD_TYPE], c[C_FRENCH],
c[C_NATIVE_LANG], c[C_PHONETIC], c[C_EXAMPLE_SENTENCE],
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();
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;
}
// ── Card import — extended format ─────────────────────────────────────────
insertLeitnerState((int) cardId, "fr_to_native", now);
insertLeitnerState((int) cardId, "native_to_fr", now);
private void importCardExtended(String[] c) {
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) {
String key = title.trim();
if (bookCache.containsKey(key)) return bookCache.get(key);
Book b = new Book();
b.title = key;
b.description = nvl(desc);
Book b = new Book(); b.title = key; b.description = nvl(desc);
long id = db.bookDao().insert(b);
bookCache.put(key, id);
return id;
......@@ -186,10 +292,7 @@ public class CsvImporter {
private long getOrCreateUnit(int bookId, int position, String title) {
String key = bookId + ":" + position;
if (unitCache.containsKey(key)) return unitCache.get(key);
Unit u = new Unit();
u.bookId = bookId;
u.position = position;
u.title = title.trim();
Unit u = new Unit(); u.bookId = bookId; u.position = position; u.title = title.trim();
long id = db.unitDao().insert(u);
unitCache.put(key, id);
return id;
......@@ -198,74 +301,79 @@ public class CsvImporter {
private long getOrCreateVolet(int unitId, int position, String title) {
String key = unitId + ":" + position;
if (voletCache.containsKey(key)) return voletCache.get(key);
Volet v = new Volet();
v.unitId = unitId;
v.position = position;
v.title = title.trim();
Volet v = new Volet(); v.unitId = unitId; v.position = position; v.title = title.trim();
long id = db.voletDao().insert(v);
voletCache.put(key, 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.voletId = voletId;
card.french = c[C_FRENCH].trim();
card.nativeLang = c[C_NATIVE_LANG].trim();
card.phonetic = nvl(c[C_PHONETIC]);
card.wordType = nvl(c[C_WORD_TYPE]);
card.exampleSentence = nvl(c[C_EXAMPLE_SENTENCE]);
card.notes = nvl(c[C_NOTES]);
card.french = french.trim();
card.nativeLang = nativeLang.trim();
card.phonetic = nvl(phonetic);
card.wordType = nvl(wordType);
card.exampleSentence = nvl(example);
card.notes = nvl(notes);
card.createdAt = now;
return db.cardDao().insert(card);
}
private void insertNounDetail(int cardId, String[] c) {
NounDetail d = new NounDetail();
d.cardId = cardId;
d.gender = nvl(c[C_GENDER]);
d.pluralForm = nvl(c[C_PLURAL_FORM]);
d.isProper = parseBool(c[C_IS_PROPER]);
db.detailDao().insertNoun(d);
}
private void insertVerbDetail(int cardId, String[] c) {
VerbDetail d = new VerbDetail();
d.cardId = cardId;
d.infinitive = nvl(c[C_INFINITIVE]);
d.verbGroup = nvl(c[C_VERB_GROUP]);
d.participePasse = nvl(c[C_PARTICIPE_PASSE]);
d.participePresent= nvl(c[C_PARTICIPE_PRESENT]);
d.auxiliary = nvl(c[C_AUXILIARY]);
d.isReflexive = parseBool(c[C_IS_REFLEXIVE]);
d.isIrregular = parseBool(c[C_IS_IRREGULAR]);
db.detailDao().insertVerb(d);
}
private void insertAdjectiveDetail(int cardId, String[] c) {
AdjectiveDetail d = new AdjectiveDetail();
d.cardId = cardId;
d.feminineForm = nvl(c[C_FEMININE_FORM]);
d.pluralForm = nvl(c[C_PLURAL_FORM_ADJ]);
d.femininePluralForm = nvl(c[C_FEM_PLURAL_FORM]);
db.detailDao().insertAdjective(d);
}
private void insertAdverbDetail(int cardId, String[] c) {
AdverbDetail d = new AdverbDetail();
d.cardId = cardId;
d.derivedFrom = nvl(c[C_DERIVED_FROM]);
db.detailDao().insertAdverb(d);
private void insertDetails(long cardId, String wordType, String[] c) {
String type = wordType == null ? "" : wordType.trim().toLowerCase();
switch (type) {
case "noun": {
NounDetail d = new NounDetail();
d.cardId = (int) cardId;
d.gender = nvl(c[C_GENDER]);
d.pluralForm = nvl(c[C_PLURAL_FORM]);
d.isProper = parseBool(c[C_IS_PROPER]);
db.detailDao().insertNoun(d);
break;
}
case "verb": {
VerbDetail d = new VerbDetail();
d.cardId = (int) cardId;
d.infinitive = nvl(c[C_INFINITIVE]);
d.verbGroup = nvl(c[C_VERB_GROUP]);
d.participePasse = nvl(c[C_PARTICIPE_PASSE]);
d.participePresent= nvl(c[C_PARTICIPE_PRESENT]);
d.auxiliary = nvl(c[C_AUXILIARY]);
d.isReflexive = parseBool(c[C_IS_REFLEXIVE]);
d.isIrregular = parseBool(c[C_IS_IRREGULAR]);
db.detailDao().insertVerb(d);
break;
}
case "adjective": {
AdjectiveDetail d = new AdjectiveDetail();
d.cardId = (int) cardId;
d.feminineForm = nvl(c[C_FEMININE_FORM]);
d.pluralForm = nvl(c[C_PLURAL_FORM_ADJ]);
d.femininePluralForm = nvl(c[C_FEM_PLURAL_FORM]);
db.detailDao().insertAdjective(d);
break;
}
case "adverb": {
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();
ls.cardId = cardId;
ls.direction = direction;
ls.boxNumber = 1;
ls.boxNumber = box;
ls.nextReviewAt = 0;
ls.updatedAt = now;
db.leitnerStateDao().insert(ls);
return db.leitnerStateDao().insert(ls);
}
// ── CSV parser ────────────────────────────────────────────────────────────
......@@ -279,15 +387,10 @@ public class CsvImporter {
if (ch == '"') {
if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
sb.append('"'); i++;
} else {
inQuotes = !inQuotes;
}
} else { inQuotes = !inQuotes; }
} else if (ch == ',' && !inQuotes) {
fields.add(sb.toString());
sb.setLength(0);
} else {
sb.append(ch);
}
fields.add(sb.toString()); sb.setLength(0);
} else { sb.append(ch); }
}
fields.add(sb.toString());
return fields.toArray(new String[0]);
......@@ -295,16 +398,35 @@ public class CsvImporter {
// ── 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) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
String t = s.trim(); return t.isEmpty() ? null : t;
}
private static int parseInt(String s, int 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) {
if (s == null) return false;
return s.trim().equalsIgnoreCase("true") || s.trim().equals("1");
......
......@@ -8,4 +8,10 @@
android:icon="@android:drawable/ic_menu_add"
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>
......@@ -2,4 +2,5 @@
<resources>
<string name="app_name">VoCoach</string>
<string name="action_add_vocabulary">Add vocabulary</string>
<string name="action_save_data">Save data</string>
</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