implement a comprehensive vocabulary export/import system with JSON support and conflict resolution

This commit is contained in:
jonasgaudian
2026-02-17 22:06:14 +01:00
parent ff77086ab1
commit 3c1e71d805
15 changed files with 1902 additions and 51 deletions

View File

@@ -33,6 +33,16 @@
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,261 @@
@file:OptIn(ExperimentalTime::class)
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
/**
* # Vocabulary Export/Import Data Models
*
* This file defines the data structures used for exporting and importing vocabulary data.
* The export format is designed to be:
* - **Portable**: Can be stored as JSON files, transmitted via REST APIs, shared via messaging apps
* - **Flexible**: Supports different export scopes (full repository, categories, individual items)
* - **Complete**: Preserves all data including learning stages, categories, and progress statistics
* - **Versioned**: Includes format version for future compatibility
*
* ## Export Scopes
*
* The system supports multiple export scopes via the [VocabularyExportData] sealed class:
* - [FullRepositoryExport]: Complete repository state with all items, categories, and mappings
* - [CategoryExport]: Single category with all its vocabulary items and their states
* - [ItemListExport]: Arbitrary list of vocabulary items with their associated data
* - [SingleItemExport]: Individual vocabulary item with its complete information
*
* ## Data Preservation
*
* Each export includes:
* - Vocabulary items (words, translations, features, creation dates)
* - Learning states (correct/incorrect counts, last answer timestamps)
* - Stage mappings (current learning stage for each item)
* - Categories (both manual tags and automatic filters)
* - Category memberships (which items belong to which categories)
* - Metadata (export date, format version, statistics)
*
* ## Usage Examples
*
* ### Exporting a full repository:
* ```kotlin
* val exportData = repository.exportFullRepository()
* val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
* // Save to file, send via API, share via WhatsApp, etc.
* ```
*
* ### Exporting a single category:
* ```kotlin
* val exportData = repository.exportCategory(categoryId)
* val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
* ```
*
* ### Importing data:
* ```kotlin
* val importData = Json.decodeFromString<VocabularyExportData>(jsonString)
* repository.importVocabularyData(importData, conflictStrategy = ConflictStrategy.MERGE)
* ```
*/
/**
* Sealed class representing different types of vocabulary exports.
* Each type contains the appropriate data for its scope.
*/
@Serializable
sealed class VocabularyExportData {
abstract val formatVersion: Int
abstract val exportDate: @Contextual Instant
abstract val metadata: ExportMetadata
}
/**
* Metadata about the export operation.
*
* @property itemCount Total number of vocabulary items included
* @property categoryCount Total number of categories included
* @property exportScope Description of what was exported
* @property appVersion Version of the app that created the export (optional)
*/
@Serializable
data class ExportMetadata(
val itemCount: Int,
val categoryCount: Int,
val exportScope: String,
val appVersion: String? = null
)
/**
* Export format for the complete repository state.
*
* This includes everything: all vocabulary items, all categories, all learning states,
* all mappings. Use this for full backups or transferring complete data between devices.
*
* @property items All vocabulary items
* @property categories All categories (tags and filters)
* @property states Learning states for all items
* @property categoryMappings Mappings between items and categories
* @property stageMappings Current learning stage for each item
*/
@Serializable
@SerialName("FullRepository")
data class FullRepositoryExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val items: List<VocabularyItem>,
val categories: List<VocabularyCategory>,
val states: List<VocabularyItemState>,
val categoryMappings: List<CategoryMappingData>,
val stageMappings: List<StageMappingData>
) : VocabularyExportData()
/**
* Export format for a single category and its vocabulary items.
*
* Use this to share a specific vocabulary list or category with others.
* All items in the category are included with their complete learning data.
*
* @property category The category being exported
* @property items All vocabulary items belonging to this category
* @property states Learning states for the items in this category
* @property stageMappings Learning stages for the items in this category
*/
@Serializable
@SerialName("Category")
data class CategoryExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val category: VocabularyCategory,
val items: List<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>
) : VocabularyExportData()
/**
* Export format for a custom list of vocabulary items.
*
* Use this to create custom vocabulary sets for sharing or studying specific words.
* Optionally includes category information if the items belong to specific categories.
*
* @property items The vocabulary items being exported
* @property states Learning states for these items
* @property stageMappings Learning stages for these items
* @property associatedCategories Categories that these items belong to (optional)
* @property categoryMappings Mappings between items and categories (optional)
*/
@Serializable
@SerialName("ItemList")
data class ItemListExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val items: List<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>,
val associatedCategories: List<VocabularyCategory> = emptyList(),
val categoryMappings: List<CategoryMappingData> = emptyList()
) : VocabularyExportData()
/**
* Export format for a single vocabulary item with all its details.
*
* Use this for sharing individual words or phrases with complete context.
*
* @property item The vocabulary item being exported
* @property state Learning state for this item (if available)
* @property stage Current learning stage for this item
* @property categories Categories this item belongs to
*/
@Serializable
@SerialName("SingleItem")
data class SingleItemExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val item: VocabularyItem,
val state: VocabularyItemState?,
val stage: VocabularyStage,
val categories: List<VocabularyCategory> = emptyList()
) : VocabularyExportData()
/**
* Simplified representation of category mapping for export/import.
*
* Maps a vocabulary item ID to a category ID. During import, IDs may be
* remapped if conflicts exist.
*/
@Serializable
data class CategoryMappingData(
val vocabularyItemId: Int,
val categoryId: Int
)
/**
* Simplified representation of stage mapping for export/import.
*
* Maps a vocabulary item ID to its current learning stage.
*/
@Serializable
data class StageMappingData(
val vocabularyItemId: Int,
val stage: VocabularyStage
)
/**
* Strategy for handling conflicts during import operations.
*
* Conflicts occur when imported data has the same IDs or content as existing data.
* Different strategies handle these conflicts in different ways.
*/
enum class ConflictStrategy {
/**
* Skip importing items that already exist (based on ID or content).
* Preserves all existing data unchanged.
*/
SKIP,
/**
* Replace existing items with imported versions.
* Overwrites local data with imported data when conflicts occur.
*/
REPLACE,
/**
* Merge imported data with existing data.
* - For vocabulary items: Keep existing if duplicate, add new ones
* - For states: Keep the more advanced learning progress
* - For categories: Merge memberships
* - For stages: Keep the higher stage
*/
MERGE,
/**
* Assign new IDs to all imported items to avoid conflicts.
* Creates duplicates rather than merging or replacing.
* Useful when importing the same data multiple times for practice.
*/
RENAME
}
/**
* Result of an import operation with statistics.
*
* @property itemsImported Number of vocabulary items successfully imported
* @property itemsSkipped Number of items skipped due to conflicts
* @property itemsUpdated Number of existing items updated
* @property categoriesImported Number of categories imported
* @property errors List of errors encountered during import (if any)
*/
data class ImportResult(
val itemsImported: Int,
val itemsSkipped: Int,
val itemsUpdated: Int,
val categoriesImported: Int,
val errors: List<String> = emptyList()
) {
val isSuccess: Boolean get() = errors.isEmpty()
val totalProcessed: Int get() = itemsImported + itemsSkipped + itemsUpdated
}

View File

@@ -15,6 +15,14 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.security.MessageDigest import java.security.MessageDigest
/**
* Enum representing different download sources.
*/
enum class DownloadSource(val baseUrl: String, val subdirectory: String) {
DICTIONARIES("http://23.88.48.47/", "dictionaries"),
FLASHCARDS("http://23.88.48.47/", "flashcard-collections")
}
/** /**
* Manages downloading files from the server, verifying checksums, and checking versions. * Manages downloading files from the server, verifying checksums, and checking versions.
*/ */
@@ -190,4 +198,128 @@ class FileDownloadManager(private val context: Context) {
fun getLocalVersion(fileId: String): String { fun getLocalVersion(fileId: String): String {
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0" return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
} }
// ===== Flashcard Collections Support =====
private val flashcardRetrofit = Retrofit.Builder()
.baseUrl(DownloadSource.FLASHCARDS.baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
/**
* Fetches the flashcard collection manifest from the server.
*/
suspend fun fetchFlashcardManifest(): FlashcardManifestResponse? = withContext(Dispatchers.IO) {
try {
val response = flashcardApiService.getFlashcardManifest().execute()
if (response.isSuccessful) {
response.body()
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch flashcard manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching flashcard manifest", e)
throw e
}
}
/**
* Downloads a flashcard collection file with checksum verification.
*/
suspend fun downloadFlashcardCollection(
flashcardInfo: FlashcardCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
val asset = flashcardInfo.asset
val source = DownloadSource.FLASHCARDS
val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}"
val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
// Create subdirectory if it doesn't exist
localFile.parentFile?.mkdirs()
try {
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
if (contentLength <= 0) {
throw Exception("Invalid file size: $contentLength")
}
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress(totalBytesRead.toFloat() / contentLength)
}
output.flush()
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Flashcard download successful for ${asset.filename}")
// Save version
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
true
} else {
Log.e("FileDownloadManager", context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
localFile.delete()
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading flashcard collection", e)
if (localFile.exists()) {
localFile.delete()
}
throw e
}
}
/**
* Checks if a newer version is available for a flashcard collection.
*/
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
return compareVersions(flashcardInfo.version, localVersion) > 0
}
/**
* Gets the local version of a flashcard collection.
*/
fun getFlashcardLocalVersion(collectionId: String): String {
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
}
} }

View File

@@ -0,0 +1,17 @@
package eu.gaudian.translator.model.communication
import retrofit2.Call
import retrofit2.http.GET
/**
* API service for fetching flashcard collection manifests and downloading files.
*/
interface FlashcardApiService {
/**
* Fetches the flashcard collection manifest from the server.
*/
@GET("flashcard-collections/manifest.json")
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
}

View File

@@ -0,0 +1,39 @@
package eu.gaudian.translator.model.communication
import com.google.gson.annotations.SerializedName
/**
* Data class representing the flashcard collection manifest response from the server.
*/
data class FlashcardManifestResponse(
@SerializedName("collections")
val collections: List<FlashcardCollectionInfo>
)
/**
* Data class representing information about a downloadable flashcard collection.
*/
data class FlashcardCollectionInfo(
@SerializedName("id")
val id: String,
@SerializedName("name")
val name: String,
@SerializedName("description")
val description: String,
@SerializedName("version")
val version: String,
@SerializedName("asset")
val asset: FlashcardAsset
)
/**
* Data class representing an asset file within a flashcard collection.
*/
data class FlashcardAsset(
@SerializedName("filename")
val filename: String,
@SerializedName("size_bytes")
val sizeBytes: Long,
@SerializedName("checksum_sha256")
val checksumSha256: String
)

View File

@@ -5,9 +5,19 @@ package eu.gaudian.translator.model.repository
import android.content.Context import android.content.Context
import androidx.room.withTransaction import androidx.room.withTransaction
import eu.gaudian.translator.model.CategoryExport
import eu.gaudian.translator.model.CategoryMappingData
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.ExportMetadata
import eu.gaudian.translator.model.FullRepositoryExport
import eu.gaudian.translator.model.ImportResult
import eu.gaudian.translator.model.ItemListExport
import eu.gaudian.translator.model.Language import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.SingleItemExport
import eu.gaudian.translator.model.StageMappingData
import eu.gaudian.translator.model.TagCategory import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyExportData
import eu.gaudian.translator.model.VocabularyFilter import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyItemState import eu.gaudian.translator.model.VocabularyItemState
@@ -45,6 +55,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.math.max
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -796,6 +807,594 @@ class VocabularyRepository private constructor(context: Context) {
} }
Log.d(TAG, "--- END REPOSITORY STATE ---") Log.d(TAG, "--- END REPOSITORY STATE ---")
} }
// ==================== EXPORT/IMPORT FUNCTIONS ====================
/**
* Exports the complete repository state including all vocabulary items, categories,
* learning states, and mappings.
*
* This creates a full backup that can be used to restore the complete state on another
* device or after data loss.
*
* @return [FullRepositoryExport] containing all repository data
*
* @see importVocabularyData for importing the exported data
* @see exportToJson for converting to JSON string
*/
suspend fun exportFullRepository(): FullRepositoryExport {
Log.i(TAG, "exportFullRepository: Creating full repository export")
val items = getAllVocabularyItems()
val categories = getAllCategories()
val states = getAllVocabularyItemStates()
val categoryMappings = getCategoryMappings()
val stageMapping = loadStageMapping().first()
return FullRepositoryExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = categories.size,
exportScope = "Full Repository"
),
items = items,
categories = categories,
states = states,
categoryMappings = categoryMappings.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) },
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
).also {
Log.i(TAG, "exportFullRepository: Export complete. Items: ${items.size}, Categories: ${categories.size}")
}
}
/**
* Exports a single category with all its vocabulary items and associated data.
*
* @param categoryId The ID of the category to export
* @return [CategoryExport] containing the category and its items, or null if category not found
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportCategory(categoryId: Int): CategoryExport? {
Log.i(TAG, "exportCategory: Exporting category id=$categoryId")
val category = getCategoryById(categoryId) ?: run {
Log.w(TAG, "exportCategory: Category id=$categoryId not found")
return null
}
val items = getVocabularyItemsByCategory(categoryId)
val itemIds = items.map { it.id }
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
return CategoryExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = 1,
exportScope = "Category: ${category.name}"
),
category = category,
items = items,
states = states,
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
).also {
Log.i(TAG, "exportCategory: Export complete. Category: ${category.name}, Items: ${items.size}")
}
}
/**
* Exports a list of vocabulary items by their IDs.
*
* @param itemIds List of vocabulary item IDs to export
* @param includeCategories Whether to include category information for these items
* @return [ItemListExport] containing the items and their data
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportItemList(itemIds: List<Int>, includeCategories: Boolean = true): ItemListExport {
Log.i(TAG, "exportItemList: Exporting ${itemIds.size} items")
val items = itemDao.getItemsByIds(itemIds)
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
val associatedCategories = if (includeCategories) {
val mappings = getCategoryMappings().filter { it.vocabularyItemId in itemIds }
val categoryIds = mappings.map { it.categoryId }.distinct()
getAllCategories().filter { it.id in categoryIds }
} else {
emptyList()
}
val categoryMappings = if (includeCategories) {
getCategoryMappings().filter { it.vocabularyItemId in itemIds }
.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) }
} else {
emptyList()
}
return ItemListExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = associatedCategories.size,
exportScope = "Item List (${items.size} items)"
),
items = items,
states = states,
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) },
associatedCategories = associatedCategories,
categoryMappings = categoryMappings
).also {
Log.i(TAG, "exportItemList: Export complete. Items: ${items.size}")
}
}
/**
* Exports a single vocabulary item with all its details.
*
* @param itemId The ID of the vocabulary item to export
* @return [SingleItemExport] containing the item and its data, or null if item not found
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportSingleItem(itemId: Int): SingleItemExport? {
Log.i(TAG, "exportSingleItem: Exporting item id=$itemId")
val item = getVocabularyItemById(itemId) ?: run {
Log.w(TAG, "exportSingleItem: Item id=$itemId not found")
return null
}
val state = getVocabularyItemStateById(itemId)
val stage = loadStageMapping().first()[itemId] ?: VocabularyStage.NEW
val mappings = getCategoryMappings().filter { it.vocabularyItemId == itemId }
val categoryIds = mappings.map { it.categoryId }
val categories = getAllCategories().filter { it.id in categoryIds }
return SingleItemExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = 1,
categoryCount = categories.size,
exportScope = "Single Item: ${item.wordFirst}"
),
item = item,
state = state,
stage = stage,
categories = categories
).also {
Log.i(TAG, "exportSingleItem: Export complete. Item: ${item.wordFirst}")
}
}
/**
* Converts any [VocabularyExportData] to a JSON string.
*
* The resulting JSON can be:
* - Saved to a file
* - Sent via REST API
* - Shared through messaging apps (WhatsApp, Telegram, etc.)
* - Stored in cloud storage (Google Drive, Dropbox, etc.)
* - Transmitted via any text-based protocol
*
* @param exportData The export data to convert
* @param prettyPrint Whether to format the JSON for human readability (default: false)
* @return JSON string representation of the export data
*
* @see importFromJson for parsing JSON back into export data
*/
fun exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String {
val json = Json {
ignoreUnknownKeys = true
this.prettyPrint = prettyPrint
}
return json.encodeToString(VocabularyExportData.serializer(), exportData)
}
/**
* Parses a JSON string into [VocabularyExportData].
*
* @param jsonString The JSON string to parse
* @return Parsed export data
* @throws kotlinx.serialization.SerializationException if JSON is invalid
*
* @see exportToJson for converting export data to JSON
* @see importVocabularyData for importing the parsed data
*/
fun importFromJson(jsonString: String): VocabularyExportData {
val json = Json { ignoreUnknownKeys = true }
return json.decodeFromString(VocabularyExportData.serializer(), jsonString)
}
/**
* Imports vocabulary data from an export.
*
* This function handles different export types (full repository, category, item list, single item)
* and applies the specified conflict resolution strategy.
*
* @param exportData The export data to import
* @param strategy The conflict resolution strategy to use (default: MERGE)
* @return [ImportResult] with statistics about the import operation
*
* @see ConflictStrategy for available strategies
* @see exportFullRepository, exportCategory, exportItemList, exportSingleItem for creating exports
*/
suspend fun importVocabularyData(
exportData: VocabularyExportData,
strategy: ConflictStrategy = ConflictStrategy.MERGE
): ImportResult {
Log.i(TAG, "importVocabularyData: Starting import with strategy=$strategy, scope=${exportData.metadata.exportScope}")
return when (exportData) {
is FullRepositoryExport -> importFullRepository(exportData, strategy)
is CategoryExport -> importCategory(exportData, strategy)
is ItemListExport -> importItemList(exportData, strategy)
is SingleItemExport -> importSingleItem(exportData, strategy)
}.also { result ->
Log.i(TAG, "importVocabularyData: Import complete. Imported: ${result.itemsImported}, " +
"Skipped: ${result.itemsSkipped}, Updated: ${result.itemsUpdated}, " +
"Categories: ${result.categoriesImported}, Errors: ${result.errors.size}")
}
}
/**
* Internal function to import a full repository export.
*/
private suspend fun importFullRepository(
export: FullRepositoryExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import categories first (they're referenced by items)
val categoryIdMap = importCategories(export.categories, strategy)
categoriesImported = categoryIdMap.size
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Import category mappings with remapped IDs
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importFullRepository: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import a category export.
*/
private suspend fun importCategory(
export: CategoryExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import the category
val categoryIdMap = importCategories(listOf(export.category), strategy)
categoriesImported = categoryIdMap.size
val newCategoryId = categoryIdMap[export.category.id] ?: export.category.id
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Create category mappings for all imported items
val mappings = itemIdMap.filter { it.value >= 0 }.map { (oldId, newId) ->
CategoryMappingData(newId, newCategoryId)
}
importCategoryMappings(mappings, mapOf(), mapOf())
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importCategory: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import an item list export.
*/
private suspend fun importItemList(
export: ItemListExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import associated categories if present
val categoryIdMap = if (export.associatedCategories.isNotEmpty()) {
importCategories(export.associatedCategories, strategy).also {
categoriesImported = it.size
}
} else {
emptyMap()
}
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Import category mappings if present
if (export.categoryMappings.isNotEmpty()) {
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
}
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importItemList: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import a single item export.
*/
private suspend fun importSingleItem(
export: SingleItemExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import categories if present
val categoryIdMap = if (export.categories.isNotEmpty()) {
importCategories(export.categories, strategy).also {
categoriesImported = it.size
}
} else {
emptyMap()
}
// Import the single item
val states = if (export.state != null) listOf(export.state) else emptyList()
val stageMappings = listOf(StageMappingData(export.item.id, export.stage))
val itemIdMap = importItems(listOf(export.item), states, stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Create category mappings
val newItemId = itemIdMap[export.item.id] ?: export.item.id
if (newItemId >= 0) {
val mappings = export.categories.map { category ->
val newCategoryId = categoryIdMap[category.id] ?: category.id
CategoryMappingData(newItemId, newCategoryId)
}
importCategoryMappings(mappings, mapOf(), mapOf())
}
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importSingleItem: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Helper function to import categories with conflict resolution.
* Returns a map of old category IDs to new category IDs.
*/
private suspend fun importCategories(
categories: List<VocabularyCategory>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
val existingCategories = getAllCategories()
for (category in categories) {
val existing = existingCategories.find { it.name == category.name && it::class == category::class }
when {
existing != null && strategy == ConflictStrategy.SKIP -> {
// Skip, but map old ID to existing ID
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Skipping existing category '${category.name}'")
}
existing != null && strategy == ConflictStrategy.REPLACE -> {
// Replace existing category
val updated = when (category) {
is TagCategory -> category.copy(id = existing.id)
is VocabularyFilter -> category.copy(id = existing.id)
}
saveCategory(updated)
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Replaced category '${category.name}'")
}
existing != null && strategy == ConflictStrategy.MERGE -> {
// Keep existing, map old ID to existing ID
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Merged with existing category '${category.name}'")
}
strategy == ConflictStrategy.RENAME || existing == null -> {
// Assign new ID
val maxId = categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0
val newId = maxId + 1
val newCategory = when (category) {
is TagCategory -> category.copy(id = newId)
is VocabularyFilter -> category.copy(id = newId)
}
saveCategory(newCategory)
idMap[category.id] = newId
Log.d(TAG, "importCategories: Created new category '${category.name}' with id=$newId")
}
}
}
return idMap
}
/**
* Helper function to import vocabulary items with their states and stage mappings.
* Returns a map of old item IDs to new item IDs (-1 means skipped).
*/
private suspend fun importItems(
items: List<VocabularyItem>,
states: List<VocabularyItemState>,
stageMappings: List<StageMappingData>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
val existingItems = getAllVocabularyItems()
val stateMap = states.associateBy { it.vocabularyItemId }
val stageMap = stageMappings.associate { it.vocabularyItemId to it.stage }
for (item in items) {
val duplicate = existingItems.find { it.isDuplicate(item) }
when {
duplicate != null && strategy == ConflictStrategy.SKIP -> {
// Skip this item
idMap[item.id] = -1
Log.d(TAG, "importItems: Skipping duplicate item '${item.wordFirst}'")
}
duplicate != null && strategy == ConflictStrategy.REPLACE -> {
// Replace with imported version
val updated = item.copy(id = duplicate.id)
itemDao.upsertItem(updated)
idMap[item.id] = duplicate.id
// Update state and stage
stateMap[item.id]?.let { state ->
stateDao.upsertState(state.copy(vocabularyItemId = duplicate.id))
}
stageMap[item.id]?.let { stage ->
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, stage))
}
Log.d(TAG, "importItems: Replaced item '${item.wordFirst}'")
}
duplicate != null && strategy == ConflictStrategy.MERGE -> {
// Merge: keep item, merge states (keep better progress)
idMap[item.id] = duplicate.id
stateMap[item.id]?.let { importedState ->
val existingState = getVocabularyItemStateById(duplicate.id)
val mergedState = mergeStates(existingState, importedState, duplicate.id)
stateDao.upsertState(mergedState)
}
stageMap[item.id]?.let { importedStage ->
val existingStage = loadStageMapping().first()[duplicate.id] ?: VocabularyStage.NEW
val mergedStage = maxOf(importedStage, existingStage)
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, mergedStage))
}
Log.d(TAG, "importItems: Merged item '${item.wordFirst}'")
}
strategy == ConflictStrategy.RENAME || duplicate == null -> {
// Assign new ID
val maxId = itemDao.getMaxItemId() ?: 0
val newId = maxId + idMap.size + 1
val newItem = item.copy(id = newId)
itemDao.upsertItem(newItem)
idMap[item.id] = newId
// Import state and stage with new ID
stateMap[item.id]?.let { state ->
stateDao.upsertState(state.copy(vocabularyItemId = newId))
}
stageMap[item.id]?.let { stage ->
mappingDao.upsertStageMapping(StageMappingEntity(newId, stage))
}
Log.d(TAG, "importItems: Created new item '${item.wordFirst}' with id=$newId")
}
}
}
return idMap
}
/**
* Helper function to import category mappings with remapped IDs.
*/
private suspend fun importCategoryMappings(
mappings: List<CategoryMappingData>,
itemIdMap: Map<Int, Int>,
categoryIdMap: Map<Int, Int>
) {
for (mapping in mappings) {
val newItemId = itemIdMap[mapping.vocabularyItemId] ?: mapping.vocabularyItemId
val newCategoryId = categoryIdMap[mapping.categoryId] ?: mapping.categoryId
// Skip if item was skipped during import
if (newItemId < 0) continue
mappingDao.addCategoryMapping(CategoryMappingEntity(newItemId, newCategoryId))
}
}
/**
* Helper function to merge two vocabulary item states.
* Keeps the more advanced learning progress.
*/
private fun mergeStates(
existing: VocabularyItemState?,
imported: VocabularyItemState,
itemId: Int
): VocabularyItemState {
if (existing == null) return imported.copy(vocabularyItemId = itemId)
return VocabularyItemState(
vocabularyItemId = itemId,
lastCorrectAnswer = maxOfNullable(existing.lastCorrectAnswer, imported.lastCorrectAnswer),
lastIncorrectAnswer = maxOfNullable(existing.lastIncorrectAnswer, imported.lastIncorrectAnswer),
correctAnswerCount = max(existing.correctAnswerCount, imported.correctAnswerCount),
incorrectAnswerCount = max(existing.incorrectAnswerCount, imported.incorrectAnswerCount)
)
}
/**
* Helper function to get the maximum of two nullable Instants.
*/
private fun maxOfNullable(a: Instant?, b: Instant?): Instant? {
return when {
a == null -> b
b == null -> a
else -> if (a > b) a else b
}
}
} }
@Serializable @Serializable

View File

@@ -118,6 +118,7 @@ fun SelectionTopBar(
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit, onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit, onMoveToStageClick: () -> Unit,
onExportClick: () -> Unit,
isRemoveEnabled: Boolean, isRemoveEnabled: Boolean,
onRemoveFromCategoryClick: () -> Unit, onRemoveFromCategoryClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -159,6 +160,14 @@ fun SelectionTopBar(
expanded = showOverflowMenu, expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false } onDismissRequest = { showOverflowMenu = false }
) { ) {
DropdownMenuItem(
text = { Text("Export Selected") },
onClick = {
onExportClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
)
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_category)) }, text = { Text(stringResource(R.string.move_to_category)) },
onClick = { onClick = {
@@ -651,6 +660,7 @@ fun SelectionTopBarPreview() {
onDeleteClick = {}, onDeleteClick = {},
onMoveToCategoryClick = {}, onMoveToCategoryClick = {},
onMoveToStageClick = {}, onMoveToStageClick = {},
onExportClick = {},
isRemoveEnabled = true, isRemoveEnabled = true,
onRemoveFromCategoryClick = {} onRemoveFromCategoryClick = {}
) )

View File

@@ -73,6 +73,7 @@ import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
import eu.gaudian.translator.view.dialogs.AddCategoryDialog import eu.gaudian.translator.view.dialogs.AddCategoryDialog
import eu.gaudian.translator.view.dialogs.CategorySelectionDialog import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
import eu.gaudian.translator.view.dialogs.StageSelectionDialog import eu.gaudian.translator.view.dialogs.StageSelectionDialog
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@@ -97,11 +98,14 @@ fun LibraryScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) } var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) }
var showFilterSheet by remember { mutableStateOf(false) } var showFilterSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -119,6 +123,7 @@ fun LibraryScreen(
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList()) val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet()) val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle() val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle()
val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
val vocabularyItemsFlow = remember(filterState) { val vocabularyItemsFlow = remember(filterState) {
vocabularyViewModel.filterVocabularyItems( vocabularyViewModel.filterVocabularyItems(
@@ -134,6 +139,16 @@ fun LibraryScreen(
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()) val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
// Handle export state
LaunchedEffect(exportState) {
if (exportState is eu.gaudian.translator.viewmodel.ExportState.Success) {
exportImportViewModel.createShareIntent()?.let { intent ->
context.startActivity(intent)
}
exportImportViewModel.resetExportState()
}
}
var isHeaderVisible by remember { mutableStateOf(true) } var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableIntStateOf(0) } var previousIndex by remember { mutableIntStateOf(0) }
var previousScrollOffset by remember { mutableIntStateOf(0) } var previousScrollOffset by remember { mutableIntStateOf(0) }
@@ -195,6 +210,11 @@ fun LibraryScreen(
}, },
onMoveToCategoryClick = { showCategoryDialog = true }, onMoveToCategoryClick = { showCategoryDialog = true },
onMoveToStageClick = { showStageDialog = true }, onMoveToStageClick = { showStageDialog = true },
onExportClick = {
val selectedIds = selection.map { it.toInt() }
exportImportViewModel.exportItemList(selectedIds)
selection = emptySet()
},
isRemoveEnabled = false, isRemoveEnabled = false,
onRemoveFromCategoryClick = {} onRemoveFromCategoryClick = {}
) )

View File

@@ -1,84 +1,167 @@
@file:Suppress("AssignedValueIsNeverRead") @file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.settings package eu.gaudian.translator.view.settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.Language import eu.gaudian.translator.model.Language
import eu.gaudian.translator.utils.StatusMessageId import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppScaffold import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.PrimaryButton import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@Composable @Composable
fun VocabularyRepositoryOptionsScreen( fun VocabularyRepositoryOptionsScreen(
navController: NavController navController: NavController
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val statusMessageService = StatusMessageService val statusMessageService = StatusMessageService
val context = LocalContext.current val context = LocalContext.current
val repositoryStateImportedFrom = stringResource(R.string.repository_state_imported_from) val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// State management
val exportState by exportImportViewModel.exportState.collectAsState()
val importState by exportImportViewModel.importState.collectAsState()
val categories by categoryViewModel.categories.collectAsState()
// Dialog states
var showExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showConflictStrategyDialog by remember { mutableStateOf(false) }
var pendingImportJson by remember { mutableStateOf<String?>(null) }
var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
// Export options
val selectedCategories = remember { mutableStateListOf<Int>() }
// Handle export/import state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
scope.launch {
snackbarHostState.showSnackbar("Export successful!")
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar((exportState as ExportState.Error).message)
}
exportImportViewModel.resetExportState()
}
else -> {}
}
}
LaunchedEffect(importState) {
when (importState) {
is ImportState.Success -> {
val result = (importState as ImportState.Success).result
scope.launch {
snackbarHostState.showSnackbar(
"Imported: ${result.itemsImported}, Skipped: ${result.itemsSkipped}, Errors: ${result.errors.size}"
)
}
exportImportViewModel.resetImportState()
}
is ImportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar((importState as ImportState.Error).message)
}
exportImportViewModel.resetImportState()
}
else -> {}
}
}
// File picker for import
val importFileLauncher = rememberLauncherForActivityResult( val importFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(), contract = ActivityResultContracts.OpenDocument(),
onResult = { uri -> onResult = { uri ->
uri?.let { uri?.let {
context.contentResolver.openInputStream(it)?.use { inputStream -> context.contentResolver.openInputStream(it)?.use { inputStream ->
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() } val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
vocabularyViewModel.importVocabulary(jsonString) pendingImportJson = jsonString
statusMessageService.showInfoMessage(repositoryStateImportedFrom + " " +it.path) showConflictStrategyDialog = true
} }
} }
} }
) )
// CSV/Excel import state // CSV/Excel import state
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val showTableImportDialog = remember { mutableStateOf(false) } val showTableImportDialog = remember { mutableStateOf(false) }
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) } var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) } var selectedColFirst by remember { mutableIntStateOf(0) }
@@ -90,7 +173,6 @@ fun VocabularyRepositoryOptionsScreen(
fun parseCsv(text: String): List<List<String>> { fun parseCsv(text: String): List<List<String>> {
if (text.isBlank()) return emptyList() if (text.isBlank()) return emptyList()
// Detect delimiter by highest occurrence among comma, semicolon, tab
val candidates = listOf(',', ';', '\t') val candidates = listOf(',', ';', '\t')
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList() val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } } val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
@@ -106,14 +188,13 @@ fun VocabularyRepositoryOptionsScreen(
'"' -> { '"' -> {
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') { if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
current.append('"') current.append('"')
i++ // skip escaped quote i++
} else { } else {
inQuotes = !inQuotes inQuotes = !inQuotes
} }
} }
'\r' -> { /* ignore, handle on \n */ } '\r' -> { /* ignore */ }
'\n' -> { '\n' -> {
// end of line
val field = current.toString() val field = current.toString()
current = StringBuilder() current = StringBuilder()
currentRow.add(if (inQuotes) field else field) currentRow.add(if (inQuotes) field else field)
@@ -133,12 +214,10 @@ fun VocabularyRepositoryOptionsScreen(
} }
i++ i++
} }
// flush last field/row if any
if (current.isNotEmpty() || currentRow.isNotEmpty()) { if (current.isNotEmpty() || currentRow.isNotEmpty()) {
currentRow.add(current.toString()) currentRow.add(current.toString())
rows.add(currentRow.toList()) rows.add(currentRow.toList())
} }
// Normalize: trim and drop trailing empty columns
return rows.map { row -> return rows.map { row ->
row.map { it.trim().trim('"') } row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } } }.filter { r -> r.any { it.isNotBlank() } }
@@ -193,8 +272,8 @@ fun VocabularyRepositoryOptionsScreen(
vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher) vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher)
} }
AppScaffold( AppScaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
AppTopAppBar( AppTopAppBar(
title = stringResource(R.string.vocabulary_repository), title = stringResource(R.string.vocabulary_repository),
@@ -209,31 +288,95 @@ fun VocabularyRepositoryOptionsScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Export Section
item { item {
// Backup and Restore Section
AppCard { AppCard {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Export Vocabulary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (exportState is ExportState.Loading) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
}
Text( Text(
text = stringResource(R.string.label_backup_and_restore), text = "Export your vocabulary data to share or backup",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Divider(modifier = Modifier.padding(vertical = 8.dp))
PrimaryButton( PrimaryButton(
onClick = { vocabularyViewModel.saveRepositoryState() }, onClick = { exportImportViewModel.exportFullRepository() },
text = stringResource(R.string.export_vocabulary_data), text = "Export Complete Repository",
modifier = Modifier.fillMaxWidth() icon = AppIcons.Download,
modifier = Modifier.fillMaxWidth(),
enabled = exportState !is ExportState.Loading
) )
SecondaryButton( SecondaryButton(
onClick = { importFileLauncher.launch(arrayOf("application/json")) }, onClick = { showExportDialog = true },
text = stringResource(R.string.import_vocabulary_data), text = "Export Selected Categories",
modifier = Modifier.fillMaxWidth() icon = AppIcons.Category,
modifier = Modifier.fillMaxWidth(),
enabled = exportState !is ExportState.Loading && categories.isNotEmpty()
) )
}
}
}
// Import Section
item {
AppCard {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Import Vocabulary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (importState is ImportState.Loading) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
}
Text(
text = "Import vocabulary from JSON files. Duplicates will be handled based on your chosen strategy.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
PrimaryButton(
onClick = { importFileLauncher.launch(arrayOf("application/json", "text/plain")) },
text = "Import from File",
icon = AppIcons.Upload,
modifier = Modifier.fillMaxWidth(),
enabled = importState !is ImportState.Loading
)
SecondaryButton( SecondaryButton(
onClick = { onClick = {
// Allow CSV and Excel mime types, but we only support CSV parsing in-app
@Suppress("HardCodedStringLiteral")
importTableLauncher.launch( importTableLauncher.launch(
arrayOf( arrayOf(
"text/csv", "text/csv",
@@ -246,11 +389,43 @@ fun VocabularyRepositoryOptionsScreen(
) )
}, },
text = stringResource(R.string.label_import_table_csv_excel), text = stringResource(R.string.label_import_table_csv_excel),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
enabled = importState !is ImportState.Loading
) )
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Conflict Strategy:",
style = MaterialTheme.typography.bodySmall
)
TextButton(onClick = { showImportDialog = true }) {
Text(
text = when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Merge (Recommended)"
ConflictStrategy.SKIP -> "Skip Duplicates"
ConflictStrategy.REPLACE -> "Replace Existing"
ConflictStrategy.RENAME -> "Keep Both"
},
style = MaterialTheme.typography.bodySmall
)
Icon(
imageVector = AppIcons.Settings,
contentDescription = "Change strategy",
modifier = Modifier.padding(start = 4.dp)
)
}
}
} }
} }
} }
// Danger Zone
item { item {
AppCard { AppCard {
Column( Column(
@@ -263,7 +438,7 @@ fun VocabularyRepositoryOptionsScreen(
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
val showConfirm = androidx.compose.runtime.remember { mutableStateOf(false) } val showConfirm = remember { mutableStateOf(false) }
AppButton( AppButton(
onClick = { showConfirm.value = true }, onClick = { showConfirm.value = true },
@@ -304,7 +479,185 @@ fun VocabularyRepositoryOptionsScreen(
} }
} }
// Export Dialog
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Export Categories") },
text = {
LazyColumn {
item {
Text(
"Select categories to export:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
}
items(categories) { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedCategories.contains(category.id),
onCheckedChange = { checked ->
if (checked) {
selectedCategories.add(category.id)
} else {
selectedCategories.removeAt(selectedCategories.indexOf(category.id))
}
}
)
Text(
text = category.name,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
if (selectedCategories.size == 1) {
exportImportViewModel.exportCategory(selectedCategories.first())
} else if (selectedCategories.isNotEmpty()) {
// Simplified: export first selected category
exportImportViewModel.exportCategory(selectedCategories.first())
}
showExportDialog = false
selectedCategories.clear()
},
enabled = selectedCategories.isNotEmpty()
) {
Text("Export")
}
},
dismissButton = {
TextButton(onClick = {
showExportDialog = false
selectedCategories.clear()
}) {
Text("Cancel")
}
}
)
}
// Import Strategy Selection Dialog
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import Conflict Strategy") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Choose how to handle duplicates during import:",
style = MaterialTheme.typography.bodyMedium
)
ConflictStrategyOption(
strategy = ConflictStrategy.MERGE,
selected = selectedConflictStrategy == ConflictStrategy.MERGE,
onSelected = { selectedConflictStrategy = ConflictStrategy.MERGE },
title = "Merge (Recommended)",
description = "Keep existing items, merge progress and categories"
)
ConflictStrategyOption(
strategy = ConflictStrategy.SKIP,
selected = selectedConflictStrategy == ConflictStrategy.SKIP,
onSelected = { selectedConflictStrategy = ConflictStrategy.SKIP },
title = "Skip Duplicates",
description = "Keep existing items unchanged, only add new ones"
)
ConflictStrategyOption(
strategy = ConflictStrategy.REPLACE,
selected = selectedConflictStrategy == ConflictStrategy.REPLACE,
onSelected = { selectedConflictStrategy = ConflictStrategy.REPLACE },
title = "Replace Existing",
description = "Overwrite existing items with imported versions"
)
ConflictStrategyOption(
strategy = ConflictStrategy.RENAME,
selected = selectedConflictStrategy == ConflictStrategy.RENAME,
onSelected = { selectedConflictStrategy = ConflictStrategy.RENAME },
title = "Keep Both",
description = "Create duplicates with new IDs"
)
}
},
confirmButton = {
TextButton(onClick = { showImportDialog = false }) {
Text("Done")
}
}
)
}
// Conflict Strategy Confirmation Dialog
if (showConflictStrategyDialog && pendingImportJson != null) {
AlertDialog(
onDismissRequest = {
showConflictStrategyDialog = false
pendingImportJson = null
},
icon = { Icon(AppIcons.Warning, contentDescription = null) },
title = { Text("Confirm Import") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Import strategy: ${
when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Merge"
ConflictStrategy.SKIP -> "Skip Duplicates"
ConflictStrategy.REPLACE -> "Replace"
ConflictStrategy.RENAME -> "Keep Both"
}
}")
Text(
when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Existing items will be kept. Progress and categories will be merged intelligently."
ConflictStrategy.SKIP -> "Only new items will be added. Existing items remain unchanged."
ConflictStrategy.REPLACE -> "Existing items will be replaced with imported versions."
ConflictStrategy.RENAME -> "All imported items will be added as new entries."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = { showImportDialog = true }) {
Text("Change Strategy")
}
}
},
confirmButton = {
TextButton(onClick = {
pendingImportJson?.let { json ->
exportImportViewModel.importFromJson(json, selectedConflictStrategy)
}
showConflictStrategyDialog = false
pendingImportJson = null
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = {
showConflictStrategyDialog = false
pendingImportJson = null
}) {
Text("Cancel")
}
}
)
}
// CSV Import Dialog
if (showTableImportDialog.value) { if (showTableImportDialog.value) {
AlertDialog( AlertDialog(
onDismissRequest = { showTableImportDialog.value = false }, onDismissRequest = { showTableImportDialog.value = false },
@@ -312,7 +665,6 @@ fun VocabularyRepositoryOptionsScreen(
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0 val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
// Column selectors
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f)) Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) } var menu1Expanded by remember { mutableStateOf(false) }
@@ -341,7 +693,6 @@ fun VocabularyRepositoryOptionsScreen(
} }
} }
} }
// Language selectors
Text(stringResource(R.string.label_languages)) Text(stringResource(R.string.label_languages))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -361,13 +712,11 @@ fun VocabularyRepositoryOptionsScreen(
) )
} }
} }
// Header toggle
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it }) Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row)) Text(stringResource(R.string.label_header_row))
} }
// Previews
val startIdx = if (skipHeader) 1 else 0 val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ") val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ") val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
@@ -425,3 +774,54 @@ fun VocabularyRepositoryOptionsScreen(
} }
} }
} }
@Composable
private fun ConflictStrategyOption(
strategy: ConflictStrategy,
selected: Boolean,
onSelected: () -> Unit,
title: String,
description: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable { onSelected() },
colors = CardDefaults.cardColors(
containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = { onSelected() }
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -15,18 +15,23 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,9 +58,12 @@ import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.viewmodel.CategoryProgress import eu.gaudian.translator.viewmodel.CategoryProgress
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@SuppressLint("ContextCastToActivity") @SuppressLint("ContextCastToActivity")
@Composable @Composable
@@ -71,12 +79,16 @@ fun CategoryDetailScreen(
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity) val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null) val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState() val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId } val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
val exportState by exportImportViewModel.exportState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val title = when (val cat = category) { val title = when (val cat = category) {
is TagCategory -> cat.name is TagCategory -> cat.name
is VocabularyFilter -> cat.name is VocabularyFilter -> cat.name
@@ -115,8 +127,32 @@ fun CategoryDetailScreen(
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false) val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false) val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false)
// Handle export state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
// Create and launch share intent
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar(
message = (exportState as ExportState.Error).message
)
}
exportImportViewModel.resetExportState()
}
else -> { /* Idle or Loading */ }
}
}
AppScaffold( AppScaffold(
modifier = modifier, modifier = modifier,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
Column( Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface) modifier = Modifier.background(MaterialTheme.colorScheme.surface)
@@ -137,15 +173,29 @@ fun CategoryDetailScreen(
modifier = Modifier.width(220.dp) modifier = Modifier.width(220.dp)
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.text_export_category)) }, text = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Export Category")
if (exportState is ExportState.Loading) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp
)
}
}
},
onClick = { onClick = {
vocabularyViewModel.saveCategory(categoryId) exportImportViewModel.exportCategory(categoryId)
showMenu = false showMenu = false
}, },
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) } leadingIcon = { Icon(AppIcons.Share, contentDescription = null) },
enabled = exportState !is ExportState.Loading
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.delete_items_category)) }, text = { Text("Delete Items") },
onClick = { onClick = {
categoryViewModel.setShowDeleteItemsDialog(true, categoryId) categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
showMenu = false showMenu = false

View File

@@ -255,17 +255,7 @@ fun NewWordScreen(
BottomActionCardsRow( BottomActionCardsRow(
onImportCsvClick = { onImportCsvClick = {
@Suppress("HardCodedStringLiteral") navController.navigate("settings_vocabulary_repository_options")
importTableLauncher.launch(
arrayOf(
"text/csv",
"text/comma-separated-values",
"text/tab-separated-values",
"text/plain",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
)
} }
) )
@@ -770,7 +760,7 @@ fun BottomActionCardsRow(
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = stringResource(R.string.label_import_csv), text = "Import Lists or CSV",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )

View File

@@ -73,6 +73,7 @@ import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
import eu.gaudian.translator.view.dialogs.StageSelectionDialog import eu.gaudian.translator.view.dialogs.StageSelectionDialog
import eu.gaudian.translator.view.library.AllCardsView import eu.gaudian.translator.view.library.AllCardsView
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@@ -110,6 +111,7 @@ fun AllCardsListScreen(
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity) val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val context = LocalContext.current val context = LocalContext.current
@@ -144,6 +146,7 @@ fun AllCardsListScreen(
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList()) val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet()) val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
val categoryNameFlow = remember(categoryId) { categoryId?.let { categoryViewModel.getCategoryById(it) } } val categoryNameFlow = remember(categoryId) { categoryId?.let { categoryViewModel.getCategoryById(it) } }
val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
val vocabularyItemsFlow: Flow<List<VocabularyItem>> = remember(filterState) { val vocabularyItemsFlow: Flow<List<VocabularyItem>> = remember(filterState) {
vocabularyViewModel.filterVocabularyItems( vocabularyViewModel.filterVocabularyItems(
@@ -161,6 +164,16 @@ fun AllCardsListScreen(
vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value
} }
// Handle export state
LaunchedEffect(exportState) {
if (exportState is eu.gaudian.translator.viewmodel.ExportState.Success) {
exportImportViewModel.createShareIntent()?.let { intent ->
context.startActivity(intent)
}
exportImportViewModel.resetExportState()
}
}
LaunchedEffect(categoryId, showDueTodayOnly, stage) { LaunchedEffect(categoryId, showDueTodayOnly, stage) {
filterState = filterState.copy( filterState = filterState.copy(
categoryIds = categoryId?.let { listOf(it) } ?: emptyList(), categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
@@ -219,6 +232,11 @@ fun AllCardsListScreen(
}, },
onMoveToCategoryClick = { showCategoryDialog = true }, onMoveToCategoryClick = { showCategoryDialog = true },
onMoveToStageClick = { showStageDialog = true }, onMoveToStageClick = { showStageDialog = true },
onExportClick = {
val selectedIds = selection.map { it.toInt() }
exportImportViewModel.exportItemList(selectedIds)
selection = emptySet()
},
onRemoveFromCategoryClick = { onRemoveFromCategoryClick = {
if (categoryId != null) { if (categoryId != null) {
val itemsToRemove = vocabularyItems.filter { selection.contains(it.id.toLong()) } val itemsToRemove = vocabularyItems.filter { selection.contains(it.id.toLong()) }
@@ -502,6 +520,7 @@ private fun ContextualTopAppBar(
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit, onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit, onMoveToStageClick: () -> Unit,
onExportClick: () -> Unit,
onRemoveFromCategoryClick: () -> Unit onRemoveFromCategoryClick: () -> Unit
) { ) {
var showOverflowMenu by remember { mutableStateOf(false) } var showOverflowMenu by remember { mutableStateOf(false) }
@@ -534,6 +553,14 @@ private fun ContextualTopAppBar(
expanded = showOverflowMenu, expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false } onDismissRequest = { showOverflowMenu = false }
) { ) {
DropdownMenuItem(
text = { Text("Export Selected") },
onClick = {
onExportClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
)
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_category)) }, text = { Text(stringResource(R.string.move_to_category)) },
onClick = { onClick = {
@@ -585,6 +612,7 @@ fun ContextualTopAppBarPreview() {
onDeleteClick = {}, onDeleteClick = {},
onMoveToCategoryClick = {}, onMoveToCategoryClick = {},
onMoveToStageClick = {}, onMoveToStageClick = {},
onExportClick = {},
onRemoveFromCategoryClick = {} onRemoveFromCategoryClick = {}
) )
} }

View File

@@ -0,0 +1,290 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.viewmodel
import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.ImportResult
import eu.gaudian.translator.model.VocabularyExportData
import eu.gaudian.translator.model.repository.VocabularyRepository
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
private const val TAG = "ExportImportViewModel"
/**
* ViewModel for handling vocabulary export and import operations.
*
* This ViewModel provides a clean separation between UI and business logic for:
* - Exporting vocabulary data (full repository, categories, items)
* - Importing vocabulary data with conflict resolution
* - Sharing exported data via Android intents
* - Managing export/import operation states
*/
@HiltViewModel
class ExportImportViewModel @Inject constructor(
application: Application,
private val repository: VocabularyRepository
) : AndroidViewModel(application) {
// UI State for export operations
private val _exportState = MutableStateFlow<ExportState>(ExportState.Idle)
val exportState: StateFlow<ExportState> = _exportState.asStateFlow()
// UI State for import operations
private val _importState = MutableStateFlow<ImportState>(ImportState.Idle)
val importState: StateFlow<ImportState> = _importState.asStateFlow()
/**
* Exports the full repository and prepares it for sharing.
*
* @param prettyPrint Whether to format JSON for human readability
*/
fun exportFullRepository(prettyPrint: Boolean = false) {
viewModelScope.launch {
try {
_exportState.value = ExportState.Loading
Log.i(TAG, "exportFullRepository: Starting full repository export")
val exportData = repository.exportFullRepository()
val jsonString = repository.exportToJson(exportData, prettyPrint)
_exportState.value = ExportState.Success(
exportData = exportData,
jsonData = jsonString
)
Log.i(TAG, "exportFullRepository: Export successful. Size: ${jsonString.length} chars")
} catch (e: Exception) {
Log.e(TAG, "exportFullRepository: Export failed", e)
_exportState.value = ExportState.Error("Failed to export repository: ${e.message}")
}
}
}
/**
* Exports a specific category.
*
* @param categoryId The ID of the category to export
* @param prettyPrint Whether to format JSON for human readability
*/
fun exportCategory(categoryId: Int, prettyPrint: Boolean = false) {
viewModelScope.launch {
try {
_exportState.value = ExportState.Loading
Log.i(TAG, "exportCategory: Starting export for category $categoryId")
val exportData: VocabularyExportData? = repository.exportCategory(categoryId)
if (exportData == null) {
_exportState.value = ExportState.Error("Category not found")
Log.w(TAG, "exportCategory: Category $categoryId not found")
return@launch
}
val jsonString = repository.exportToJson(exportData, prettyPrint)
_exportState.value = ExportState.Success(
exportData = exportData,
jsonData = jsonString
)
} catch (e: Exception) {
Log.e(TAG, "exportCategory: Export failed", e)
_exportState.value = ExportState.Error("Failed to export category: ${e.message}")
}
}
}
/**
* Exports a list of vocabulary items.
*
* @param itemIds List of item IDs to export
* @param includeCategories Whether to include category information
* @param prettyPrint Whether to format JSON for human readability
*/
fun exportItemList(itemIds: List<Int>, includeCategories: Boolean = true, prettyPrint: Boolean = false) {
viewModelScope.launch {
try {
_exportState.value = ExportState.Loading
Log.i(TAG, "exportItemList: Starting export for ${itemIds.size} items")
val exportData: VocabularyExportData = repository.exportItemList(itemIds, includeCategories)
val jsonString = repository.exportToJson(exportData, prettyPrint)
_exportState.value = ExportState.Success(
exportData = exportData,
jsonData = jsonString
)
} catch (e: Exception) {
Log.e(TAG, "exportItemList: Export failed", e)
_exportState.value = ExportState.Error("Failed to export items: ${e.message}")
}
}
}
/**
* Exports a single vocabulary item.
*
* @param itemId The ID of the item to export
* @param prettyPrint Whether to format JSON for human readability
*/
fun exportSingleItem(itemId: Int, prettyPrint: Boolean = false) {
viewModelScope.launch {
try {
_exportState.value = ExportState.Loading
Log.i(TAG, "exportSingleItem: Starting export for item $itemId")
val exportData: VocabularyExportData? = repository.exportSingleItem(itemId)
if (exportData == null) {
_exportState.value = ExportState.Error("Item not found")
Log.w(TAG, "exportSingleItem: Item $itemId not found")
return@launch
}
val jsonString = repository.exportToJson(exportData, prettyPrint)
_exportState.value = ExportState.Success(
exportData = exportData,
jsonData = jsonString
)
Log.i(TAG, "exportSingleItem: Export successful")
} catch (e: Exception) {
Log.e(TAG, "exportSingleItem: Export failed", e)
_exportState.value = ExportState.Error("Failed to export item: ${e.message}")
}
}
}
/**
* Creates a share intent for the exported data file.
* Uses FileProvider to share large files without hitting binder transaction limits.
*/
fun createShareIntent(): Intent? {
val currentState = _exportState.value
if (currentState !is ExportState.Success) {
Log.w(TAG, "createShareIntent: No export data available")
return null
}
return try {
// Write to cache file
val context = getApplication<Application>()
val cacheDir = File(context.cacheDir, "exports")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
val timestamp = System.currentTimeMillis()
val fileName = "polly_vocabulary_export_$timestamp.json"
val file = File(cacheDir, fileName)
file.writeText(currentState.jsonData)
Log.i(TAG, "createShareIntent: Wrote export to file: ${file.absolutePath}, size: ${file.length()} bytes")
// Create content URI using FileProvider
val contentUri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/json"
putExtra(Intent.EXTRA_STREAM, contentUri)
putExtra(Intent.EXTRA_SUBJECT, "Polly Vocabulary Export")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
Intent.createChooser(intent, "Share Vocabulary Export")
} catch (e: Exception) {
Log.e(TAG, "createShareIntent: Error creating share intent", e)
_exportState.value = ExportState.Error("Failed to create share intent: ${e.message}")
null
}
}
/**
* Imports vocabulary data from JSON string.
*
* @param jsonString The JSON string to import
* @param strategy The conflict resolution strategy to use
*/
fun importFromJson(jsonString: String, strategy: ConflictStrategy = ConflictStrategy.MERGE) {
viewModelScope.launch {
try {
_importState.value = ImportState.Loading
Log.i(TAG, "importFromJson: Starting import with strategy=$strategy")
val exportData = repository.importFromJson(jsonString)
val result = repository.importVocabularyData(exportData, strategy)
_importState.value = ImportState.Success(result)
Log.i(TAG, "importFromJson: Import complete. Imported: ${result.itemsImported}, " +
"Skipped: ${result.itemsSkipped}, Errors: ${result.errors.size}")
} catch (e: Exception) {
Log.e(TAG, "importFromJson: Import failed", e)
_importState.value = ImportState.Error("Failed to import: ${e.message}")
}
}
}
/**
* Resets the export state to idle.
* Call this after handling export results.
*/
fun resetExportState() {
_exportState.value = ExportState.Idle
}
/**
* Resets the import state to idle.
* Call this after handling import results.
*/
fun resetImportState() {
_importState.value = ImportState.Idle
}
}
/**
* Sealed class representing the state of an export operation.
*/
sealed class ExportState {
/** No export operation in progress */
object Idle : ExportState()
/** Export operation in progress */
object Loading : ExportState()
/** Export completed successfully */
data class Success(
val exportData: VocabularyExportData,
val jsonData: String
) : ExportState()
/** Export failed with an error */
data class Error(val message: String) : ExportState()
}
/**
* Sealed class representing the state of an import operation.
*/
sealed class ImportState {
/** No import operation in progress */
object Idle : ImportState()
/** Import operation in progress */
object Loading : ImportState()
/** Import completed successfully */
data class Success(val result: ImportResult) : ImportState()
/** Import failed with an error */
data class Error(val message: String) : ImportState()
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Cache directory for temporary export files -->
<cache-path name="exports" path="exports/" />
</paths>