"src/co2_sensor.cpp" did not exist on "234b5299855346e489de27467c0fde2ddf931b12"
Commit 8fb81ab9 authored by Lückemeyer's avatar Lückemeyer
Browse files

claude added vocabulary file import based on previously separate import tool, 3 iterations

parent 6a530eed
......@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="dev.lueckemeyer.vocoach">
<!-- 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" />
<application
android:name=".VoCoachApp"
android:allowBackup="true"
......
package dev.lueckemeyer.vocoach;
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;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import dev.lueckemeyer.vocoach.databinding.ActivityMainBinding;
import dev.lueckemeyer.vocoach.importer.CsvImporter;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private final ExecutorService exec = Executors.newSingleThreadExecutor();
// ── File picker launcher ──────────────────────────────────────────────────
private final ActivityResultLauncher<Intent> filePickerLauncher =
registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
if (uri != null) startImport(uri);
}
});
@Override
protected void onCreate(Bundle savedInstanceState) {
......@@ -24,8 +53,84 @@ public class MainActivity extends AppCompatActivity {
.findFragmentById(R.id.nav_host_fragment);
NavController navController = navHostFragment.getNavController();
// Home is top-level — no back arrow there; all others get one.
AppBarConfiguration appBarConfig = new AppBarConfiguration.Builder(R.id.homeFragment).build();
AppBarConfiguration appBarConfig =
new AppBarConfiguration.Builder(R.id.homeFragment).build();
NavigationUI.setupWithNavController(binding.toolbar, navController, appBarConfig);
}
// ── Options menu ──────────────────────────────────────────────────────────
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.action_add_vocabulary) {
openFilePicker();
return true;
}
return super.onOptionsItemSelected(item);
}
// ── File picker ───────────────────────────────────────────────────────────
private void openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); // text/csv not always recognised
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)
.setTitle("Importing vocabulary…")
.setView(progressBar)
.setCancelable(false)
.create();
progressDialog.show();
CsvImporter importer = new CsvImporter(this);
exec.execute(() -> {
CsvImporter.Result result = importer.importFromUri(uri);
runOnUiThread(() -> {
progressDialog.dismiss();
showResult(result);
});
});
}
private void showResult(CsvImporter.Result result) {
if (result.errorMessage != null) {
new AlertDialog.Builder(this)
.setTitle("Import failed")
.setMessage(result.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.";
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
}
}
}
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.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.
*
* 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.
*/
public class CsvImporter {
// ── Column indices — must match the CSV header ────────────────────────────
private static final int C_WORD_TYPE = 0;
private static final int C_FRENCH = 1;
private static final int C_NATIVE_LANG = 2;
private static final int C_PHONETIC = 3;
private static final int C_EXAMPLE_SENTENCE = 4;
private static final int C_NOTES = 5;
private static final int C_BOOK_TITLE = 6;
private static final int C_BOOK_DESC = 7;
private static final int C_UNIT_POSITION = 8;
private static final int C_UNIT_TITLE = 9;
private static final int C_VOLET_POSITION = 10;
private static final int C_VOLET_TITLE = 11;
private static final int C_GENDER = 12;
private static final int C_PLURAL_FORM = 13;
private static final int C_IS_PROPER = 14;
private static final int C_INFINITIVE = 15;
private static final int C_VERB_GROUP = 16;
private static final int C_PARTICIPE_PASSE = 17;
private static final int C_PARTICIPE_PRESENT = 18;
private static final int C_AUXILIARY = 19;
private static final int C_IS_REFLEXIVE = 20;
private static final int C_IS_IRREGULAR = 21;
private static final int C_FEMININE_FORM = 22;
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 ──────────────────────────────────────────
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;
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<>();
private final VocabDatabase db;
private final Context context;
public CsvImporter(Context context) {
this.context = context.getApplicationContext();
this.db = VocabDatabase.getInstance(this.context);
}
// ── 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;
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.");
String[] headerCols = parseCsvLine(header);
if (headerCols.length < EXPECTED_COLUMNS) {
return new Result(0, 0,
"CSV has only " + headerCols.length +
" columns; expected " + EXPECTED_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] = "";
}
importRow(cols);
cardCount++;
} catch (Exception e) {
errorCount++;
}
}
} catch (Exception e) {
return new Result(cardCount, errorCount,
"Import failed: " + e.getMessage());
}
return new Result(cardCount, errorCount, null);
}
// ── Load existing parents into cache ──────────────────────────────────────
private void loadExistingParents() {
// Books keyed by title
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)) {
unitCache.put(b.id + ":" + u.position, (long) u.id);
}
}
// Volets keyed by unit_id:position
for (Volet v : db.voletDao().getAllSync()) {
voletCache.put(v.unitId + ":" + v.position, (long) v.id);
}
}
// ── Row import ────────────────────────────────────────────────────────────
private void importRow(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);
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;
}
insertLeitnerState((int) cardId, "fr_to_native", now);
insertLeitnerState((int) cardId, "native_to_fr", now);
}
// ── Entity upserts ────────────────────────────────────────────────────────
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);
long id = db.bookDao().insert(b);
bookCache.put(key, id);
return id;
}
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();
long id = db.unitDao().insert(u);
unitCache.put(key, id);
return id;
}
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();
long id = db.voletDao().insert(v);
voletCache.put(key, id);
return id;
}
private long insertCard(int voletId, String[] c, 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.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 insertLeitnerState(int cardId, String direction, long now) {
LeitnerState ls = new LeitnerState();
ls.cardId = cardId;
ls.direction = direction;
ls.boxNumber = 1;
ls.nextReviewAt = 0;
ls.updatedAt = now;
db.leitnerStateDao().insert(ls);
}
// ── CSV parser ────────────────────────────────────────────────────────────
private static String[] parseCsvLine(String line) {
List<String> fields = new ArrayList<>();
StringBuilder sb = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char ch = line.charAt(i);
if (ch == '"') {
if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
sb.append('"'); i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch == ',' && !inQuotes) {
fields.add(sb.toString());
sb.setLength(0);
} else {
sb.append(ch);
}
}
fields.add(sb.toString());
return fields.toArray(new String[0]);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static String nvl(String s) {
if (s == null) return null;
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 boolean parseBool(String s) {
if (s == null) return false;
return s.trim().equalsIgnoreCase("true") || s.trim().equals("1");
}
}
......@@ -5,5 +5,5 @@
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#3F51B5"/>
android:fillColor="@color/purple_500"/>
</vector>
......@@ -11,6 +11,7 @@
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.MaterialComponents.Light"
app:title="VoCoach"
app:titleTextColor="@android:color/white" />
......
<?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/action_add_vocabulary"
android:title="@string/action_add_vocabulary"
android:icon="@android:drawable/ic_menu_add"
app:showAsAction="never"/>
</menu>
......@@ -8,4 +8,5 @@
<color name="white">#FFFFFFFF</color>
<color name="correct">#FF2E7D32</color>
<color name="wrong">#FFC62828</color>
<color name="text_primary_light">#000000</color>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VoCoach</string>
<string name="action_add_vocabulary">Add vocabulary</string>
</resources>
......@@ -5,5 +5,6 @@
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="android:textColorPrimary">@color/text_primary_light</item>
</style>
</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