465 lines
16 KiB
Kotlin

package com.shaarit.presentation.add
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.metadata.LinkMetadataExtractor
import com.shaarit.domain.model.AiEnrichmentResult
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.AddLinkResult
import com.shaarit.domain.repository.LinkRepository
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.net.URLDecoder
import javax.inject.Inject
@HiltViewModel
class AddLinkViewModel
@Inject
constructor(
private val linkRepository: LinkRepository,
private val metadataExtractor: LinkMetadataExtractor,
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
private val initialUrl: String? = savedStateHandle["url"]
private val initialTitle: String? = savedStateHandle["title"]
private val initialDescription: String? = savedStateHandle["description"]
private val initialTags: String? = savedStateHandle["tags"]
private val isFileShare: Boolean = savedStateHandle["isFileShare"] ?: false
private val _uiState = MutableStateFlow<AddLinkUiState>(AddLinkUiState.Idle)
val uiState = _uiState.asStateFlow()
var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "")
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
var description = MutableStateFlow(decodeUrlParam(initialDescription) ?: "")
var isPrivate = MutableStateFlow(false)
// Content type selection - default to NOTE for file shares
private val _contentTypeSelection = MutableStateFlow(if (isFileShare) ContentType.NOTE else ContentType.BOOKMARK)
val contentTypeSelection = _contentTypeSelection.asStateFlow()
// Extraction state
private val _isExtractingMetadata = MutableStateFlow(false)
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
private val _extractedThumbnail = MutableStateFlow<String?>(null)
val extractedThumbnail = _extractedThumbnail.asStateFlow()
private val _contentType = MutableStateFlow<String?>(null)
val contentType = _contentType.asStateFlow()
// New tag management
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
val selectedTags = _selectedTags.asStateFlow()
private val _newTagInput = MutableStateFlow("")
val newTagInput = _newTagInput.asStateFlow()
private val _availableTags = MutableStateFlow<List<ShaarliTag>>(emptyList())
val availableTags = _availableTags.asStateFlow()
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
val tagSuggestions = _tagSuggestions.asStateFlow()
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
private val _aiErrorMessage = MutableSharedFlow<String>()
val aiErrorMessage = _aiErrorMessage.asSharedFlow()
// For conflict handling
private var conflictLinkId: Int? = null
init {
loadAvailableTags()
setupUrlMetadataExtraction()
// Handle file share - add initial tags
if (isFileShare) {
// Parse and add initial tags from file share
initialTags?.let { tagsParam ->
val decodedTags = decodeUrlParam(tagsParam)
decodedTags?.split(",")?.forEach { tag ->
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank()) {
_selectedTags.value = _selectedTags.value + cleanTag
}
}
}
}
// Si une URL initiale est fournie, extraire les métadonnées
if (!initialUrl.isNullOrBlank() && !isFileShare) {
extractMetadata(initialUrl)
}
}
@OptIn(FlowPreview::class)
private fun setupUrlMetadataExtraction() {
url
.debounce(1000) // Attendre 1s après la fin de la saisie
.onEach { urlString ->
if (urlString.isNotBlank() && urlString.startsWith("http")) {
extractMetadata(urlString)
}
}
.launchIn(viewModelScope)
}
private fun extractMetadata(urlString: String) {
viewModelScope.launch {
_isExtractingMetadata.value = true
// Launch JSoup metadata extraction and AI analysis in parallel
val metadataDeferred = async {
try {
metadataExtractor.extract(urlString)
} catch (e: Exception) {
null
}
}
val aiDeferred = if (analyzeUrlWithAiUseCase.isApiKeyConfigured() && _aiEnrichmentState.value != AiEnrichmentState.Loading) {
_aiEnrichmentState.value = AiEnrichmentState.Loading
async {
analyzeUrlWithAiUseCase(urlString)
}
} else null
// Apply JSoup metadata as soon as it arrives
val metadata = metadataDeferred.await()
if (metadata != null) {
if (title.value.isBlank() && !metadata.title.isNullOrBlank()) {
title.value = metadata.title
}
if (description.value.isBlank() && !metadata.description.isNullOrBlank()) {
description.value = metadata.description
}
_extractedThumbnail.value = metadata.thumbnailUrl
_contentType.value = metadata.contentType.name
metadata.siteName?.let { site -> suggestTagFromSite(site) }
}
_isExtractingMetadata.value = false
// Complete with AI enrichment
aiDeferred?.await()
?.onSuccess { result ->
applyAiEnrichment(result)
_aiEnrichmentState.value = AiEnrichmentState.Success
}
?.onFailure {
_aiEnrichmentState.value = AiEnrichmentState.Idle
}
}
}
private fun suggestTagFromSite(siteName: String) {
val siteTag = siteName.lowercase().replace(" ", "-").replace(".", "")
if (siteTag !in _selectedTags.value) {
// Ajouter automatiquement certains tags connus
when (siteTag) {
"youtube", "vimeo" -> addTag("video")
"github", "gitlab" -> addTag("dev")
"twitter", "x" -> addTag("social")
"reddit" -> addTag("reddit")
"medium", "devto" -> addTag("article")
}
}
}
/** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */
private fun decodeUrlParam(param: String?): String? {
if (param.isNullOrBlank()) return null
return try {
URLDecoder.decode(param, "UTF-8").replace("+", " ").trim()
} catch (e: Exception) {
param.replace("+", " ").trim()
}
}
private fun loadAvailableTags() {
viewModelScope.launch {
linkRepository
.getTags()
.fold(
onSuccess = { tags ->
_availableTags.value = tags.sortedByDescending { it.occurrences }
},
onFailure = {
// Silently fail - tags are optional
}
)
}
}
fun onNewTagInputChanged(input: String) {
_newTagInput.value = input
updateTagSuggestions(input)
}
private fun updateTagSuggestions(query: String) {
if (query.isBlank()) {
_tagSuggestions.value = emptyList()
return
}
val queryLower = query.lowercase()
_tagSuggestions.value =
_availableTags
.value
.filter {
it.name.lowercase().contains(queryLower) &&
it.name !in _selectedTags.value
}
.take(10)
}
fun addTag(tag: String) {
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank() && cleanTag !in _selectedTags.value) {
_selectedTags.value = _selectedTags.value + cleanTag
_newTagInput.value = ""
_tagSuggestions.value = emptyList()
}
}
fun addNewTag() {
addTag(_newTagInput.value)
}
fun analyzeUrlWithAi() {
val currentUrl = url.value
if (currentUrl.isBlank()) {
viewModelScope.launch {
_aiErrorMessage.emit("Veuillez d'abord entrer une URL")
}
return
}
if (!analyzeUrlWithAiUseCase.isApiKeyConfigured()) {
viewModelScope.launch {
_aiErrorMessage.emit("Clé API Gemini non configurée. Allez dans Paramètres.")
}
return
}
viewModelScope.launch {
_aiEnrichmentState.value = AiEnrichmentState.Loading
analyzeUrlWithAiUseCase(currentUrl)
.onSuccess { result ->
applyAiEnrichment(result)
_aiEnrichmentState.value = AiEnrichmentState.Success
}
.onFailure { error ->
_aiEnrichmentState.value = AiEnrichmentState.Idle
_aiErrorMessage.emit(error.message ?: "Erreur lors de l'analyse IA")
}
}
}
private fun applyAiEnrichment(result: AiEnrichmentResult) {
title.value = result.title
description.value = result.description
// Add AI-generated tags to existing tags (without duplicates)
val currentTags = _selectedTags.value.toMutableSet()
result.tags.forEach { tag ->
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank()) {
currentTags.add(cleanTag)
}
}
_selectedTags.value = currentTags.toList()
}
fun resetAiEnrichmentState() {
_aiEnrichmentState.value = AiEnrichmentState.Idle
}
fun removeTag(tag: String) {
_selectedTags.value = _selectedTags.value - tag
}
fun addLink() {
viewModelScope.launch {
_uiState.value = AddLinkUiState.Loading
val currentUrl = url.value
val currentTitle = title.value
// Validation based on content type
when (_contentTypeSelection.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
return@launch
}
}
ContentType.NOTE -> {
if (currentTitle.isBlank()) {
_uiState.value = AddLinkUiState.Error("Title is required for notes")
return@launch
}
}
}
var finalUrl = currentUrl
var finalTitle = title.value
if (_contentTypeSelection.value == ContentType.NOTE) {
if (finalUrl.isBlank()) {
finalUrl = "https://shaarit.app/note/${generateRandomId()}"
}
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle"
}
}
val result =
linkRepository.addOrUpdateLink(
url = finalUrl,
title = finalTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = false,
existingLinkId = null
)
when (result) {
is AddLinkResult.Success -> {
_uiState.value = AddLinkUiState.Success
}
is AddLinkResult.Conflict -> {
conflictLinkId = result.existingLinkId
_uiState.value =
AddLinkUiState.Conflict(
result.existingLinkId,
result.existingTitle
)
}
is AddLinkResult.Error -> {
_uiState.value = AddLinkUiState.Error(result.message)
}
}
}
}
fun forceUpdateExistingLink() {
viewModelScope.launch {
val currentUrl = url.value
val currentTitle = title.value
// Validation based on content type
when (_contentTypeSelection.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
return@launch
}
}
ContentType.NOTE -> {
if (currentTitle.isBlank()) {
_uiState.value = AddLinkUiState.Error("Title is required for notes")
return@launch
}
}
}
_uiState.value = AddLinkUiState.Loading
var finalUrl = currentUrl
var finalTitle = title.value
if (_contentTypeSelection.value == ContentType.NOTE) {
if (finalUrl.isBlank()) {
finalUrl = "https://shaarit.app/note/${generateRandomId()}"
}
if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) {
finalTitle = "Note: $finalTitle"
}
}
val result =
linkRepository.addOrUpdateLink(
url = finalUrl,
title = finalTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = true,
existingLinkId = conflictLinkId
)
when (result) {
is AddLinkResult.Success -> {
_uiState.value = AddLinkUiState.Success
}
is AddLinkResult.Error -> {
_uiState.value = AddLinkUiState.Error(result.message)
}
else -> {
_uiState.value = AddLinkUiState.Error("Unexpected result")
}
}
}
}
fun dismissConflict() {
_uiState.value = AddLinkUiState.Idle
conflictLinkId = null
}
fun setContentType(type: ContentType) {
_contentTypeSelection.value = type
// Auto-add "note" tag when Note type is selected
if (type == ContentType.NOTE && "note" !in _selectedTags.value) {
addTag("note")
} else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) {
// Remove "note" tag when switching back to Bookmark
removeTag("note")
}
}
private fun generateRandomId(length: Int = 6): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length)
.map { chars.random() }
.joinToString("")
}
}
sealed class AddLinkUiState {
object Idle : AddLinkUiState()
object Loading : AddLinkUiState()
object Success : AddLinkUiState()
data class Error(val message: String) : AddLinkUiState()
data class Conflict(val existingLinkId: Int, val existingTitle: String?) : AddLinkUiState()
}
enum class ContentType {
BOOKMARK,
NOTE
}
sealed class AiEnrichmentState {
object Idle : AiEnrichmentState()
object Loading : AiEnrichmentState()
object Success : AiEnrichmentState()
}