You need to sign in or sign up before continuing.
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"});
filePickerLauncher.launch(intent);
}
// Pre-select the Downloads folder private void startImport(Uri uri) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { ProgressBar pb = new ProgressBar(this);
Uri downloadsUri = android.provider.DocumentsContract.buildRootUri( pb.setPadding(64, 64, 64, 64);
"com.android.externalstorage.documents", "primary"); AlertDialog dialog = new AlertDialog.Builder(this)
intent.putExtra(android.provider.DocumentsContract.EXTRA_INITIAL_URI, downloadsUri); .setTitle("Importing vocabulary…")
.setView(pb)
.setCancelable(false)
.create();
dialog.show();
exec.execute(() -> {
CsvImporter.Result result = new CsvImporter(this).importFromUri(uri);
runOnUiThread(() -> {
dialog.dismiss();
showImportResult(result);
});
});
} }
filePickerLauncher.launch(intent); private void showImportResult(CsvImporter.Result r) {
if (r.errorMessage != null) {
new AlertDialog.Builder(this)
.setTitle("Import failed")
.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();
}
} }
// ── Import ──────────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────────
private void startImport(Uri uri) { private void openFileSaver() {
// Show a non-cancelable progress dialog while importing String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
ProgressBar progressBar = new ProgressBar(this); .format(new Date());
progressBar.setPadding(64, 64, 64, 64); String filename = "vocoach_" + timestamp + ".csv";
AlertDialog progressDialog = new AlertDialog.Builder(this) Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
.setTitle("Importing vocabulary…") intent.addCategory(Intent.CATEGORY_OPENABLE);
.setView(progressBar) 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) .setCancelable(false)
.create(); .create();
progressDialog.show(); dialog.show();
CsvImporter importer = new CsvImporter(this);
exec.execute(() -> { exec.execute(() -> {
CsvImporter.Result result = importer.importFromUri(uri); CsvExporter.Result result = new CsvExporter(this).exportToUri(uri);
runOnUiThread(() -> { runOnUiThread(() -> {
progressDialog.dismiss(); dialog.dismiss();
showResult(result); showExportResult(result);
}); });
}); });
} }
private void showResult(CsvImporter.Result result) { private void showExportResult(CsvExporter.Result r) {
if (result.errorMessage != null) { if (r.errorMessage != null) {
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle("Import failed") .setTitle("Save failed")
.setMessage(result.errorMessage) .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;
}
}
...@@ -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