465 lines
16 KiB
Kotlin
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()
|
|
}
|