From 02c7300c3b78d70db92b922b5675580a33e43a6b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 31 Jan 2026 13:19:53 -0500 Subject: [PATCH] feat: Implement link health monitoring system with periodic checks and dead link detection - Add LinkHealthCheckWorker for automated bi-daily link validation (12-hour intervals) - Extend LinkEntity with isDeadLink and lastHealthCheck fields for tracking link status - Create LinkDao queries for dead link retrieval and health status updates - Implement getDeadLinksStream() in LinkRepository for paginated dead link viewing - Add GeminiRepository.generateTags() method with model fallback strategy and --- app/src/main/java/com/shaarit/ShaarItApp.kt | 22 +++ .../com/shaarit/data/local/dao/LinkDao.kt | 19 +++ .../shaarit/data/local/entity/LinkEntity.kt | 8 +- .../data/repository/GeminiRepositoryImpl.kt | 114 ++++++++++++-- .../data/repository/LinkRepositoryImpl.kt | 12 +- .../data/worker/LinkHealthCheckWorker.kt | 81 ++++++++++ .../java/com/shaarit/domain/model/Models.kt | 3 +- .../domain/repository/GeminiRepository.kt | 1 + .../domain/repository/LinkRepository.kt | 2 + .../usecase/GenerateTagsWithAiUseCase.kt | 19 +++ .../presentation/deadlinks/DeadLinksScreen.kt | 144 ++++++++++++++++++ .../deadlinks/DeadLinksViewModel.kt | 28 ++++ .../presentation/edit/EditLinkScreen.kt | 64 ++++++++ .../presentation/edit/EditLinkViewModel.kt | 93 ++++++++++- .../shaarit/presentation/feed/FeedScreen.kt | 10 ++ .../presentation/feed/LinkItemViews.kt | 63 ++++++-- .../com/shaarit/presentation/nav/NavGraph.kt | 13 ++ 17 files changed, 661 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt create mode 100644 app/src/main/java/com/shaarit/domain/usecase/GenerateTagsWithAiUseCase.kt create mode 100644 app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt index d6557f2..e7b728e 100644 --- a/app/src/main/java/com/shaarit/ShaarItApp.kt +++ b/app/src/main/java/com/shaarit/ShaarItApp.kt @@ -3,7 +3,12 @@ package com.shaarit import android.app.Application import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.shaarit.data.worker.LinkHealthCheckWorker import dagger.hilt.android.HiltAndroidApp +import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltAndroidApp @@ -13,4 +18,21 @@ class ShaarItApp : Application(), Configuration.Provider { override val workManagerConfiguration: Configuration get() = Configuration.Builder().setWorkerFactory(workerFactory).build() + + override fun onCreate() { + super.onCreate() + setupHealthCheckWorker() + } + + private fun setupHealthCheckWorker() { + val healthCheckRequest = PeriodicWorkRequestBuilder( + 12, TimeUnit.HOURS // Run twice a day + ).build() + + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + LinkHealthCheckWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + healthCheckRequest + ) + } } diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt index e4bd7a2..cf7c879 100644 --- a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt +++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt @@ -96,6 +96,25 @@ interface LinkDao { """) suspend fun countLinksBySearch(query: String): Int + @Query(""" + SELECT * FROM links + WHERE is_dead_link = 1 + ORDER BY last_health_check DESC + """) + fun getDeadLinks(): PagingSource + + @Query(""" + SELECT * FROM links + WHERE url NOT LIKE 'note://%' + AND (last_health_check < :timestamp OR last_health_check IS NULL) + ORDER BY last_health_check ASC + LIMIT :limit + """) + suspend fun getLinksForHealthCheck(timestamp: Long, limit: Int): List + + @Query("UPDATE links SET is_dead_link = :isDead, last_health_check = :timestamp WHERE id = :id") + suspend fun updateLinkHealthStatus(id: Int, isDead: Boolean, timestamp: Long) + // ====== Filtres temporels ====== @Query(""" diff --git a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt index 174840e..0476baf 100644 --- a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt +++ b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt @@ -69,7 +69,13 @@ data class LinkEntity( val siteName: String? = null, @ColumnInfo(name = "excerpt") - val excerpt: String? = null + val excerpt: String? = null, + + @ColumnInfo(name = "is_dead_link") + val isDeadLink: Boolean = false, + + @ColumnInfo(name = "last_health_check") + val lastHealthCheck: Long = 0 ) /** diff --git a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt index fa861d7..c131f25 100644 --- a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt @@ -20,6 +20,9 @@ class GeminiRepositoryImpl @Inject constructor( private val tokenManager: TokenManager ) : GeminiRepository { + // Cache pour les tags + private val tagsCache = mutableMapOf>() + // Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session private val analysisCache = mutableMapOf() @@ -27,6 +30,86 @@ class GeminiRepositoryImpl @Inject constructor( return !tokenManager.getGeminiApiKey().isNullOrBlank() } + override suspend fun generateTags(title: String, description: String): Result> = withContext(Dispatchers.IO) { + val cacheKey = "$title|$description" + if (tagsCache.containsKey(cacheKey)) { + return@withContext Result.success(tagsCache[cacheKey]!!) + } + + try { + val apiKey = tokenManager.getGeminiApiKey() + if (apiKey.isNullOrBlank()) { + return@withContext Result.failure(Exception("Clé API Gemini non configurée.")) + } + + val modelsToTry = listOf( + "gemini-2.5-flash-lite", + "gemini-2.5-flash", + "gemini-3-flash", + "gemini-2.0-flash-lite", + "gemini-1.5-flash" + ) + var lastException: Exception? = null + + for (modelName in modelsToTry) { + try { + val tags = generateTagsWithModel(apiKey, modelName, title, description) + tagsCache[cacheKey] = tags + return@withContext Result.success(tags) + } catch (e: Exception) { + lastException = e + val msg = (e.message ?: "").lowercase() + val isRetryable = msg.contains("404") || msg.contains("not found") || + msg.contains("429") || msg.contains("quota") || + msg.contains("exhausted") + if (!isRetryable) break + } + } + + Result.failure(lastException ?: Exception("Impossible de générer des tags")) + } catch (e: Exception) { + Result.failure(e) + } + } + + private suspend fun generateTagsWithModel(apiKey: String, modelName: String, title: String, description: String): List { + val generativeModel = GenerativeModel( + modelName = modelName, + apiKey = apiKey, + generationConfig = generationConfig { + temperature = 0.5f // Plus bas pour des tags précis + maxOutputTokens = 512 + } + ) + + val prompt = """ + Tâche: Générer des tags pertinents pour ce contenu. + Titre: $title + Description: $description + + Consignes: + 1. Suggère 5 à 10 tags pertinents. + 2. Tags en lowercase, mots simples ou composés avec tirets. + 3. Réponds UNIQUEMENT avec un tableau JSON de chaînes. + Exemple: ["android", "kotlin", "dev-tools"] + """.trimIndent() + + val response = generativeModel.generateContent(content { text(prompt) }) + val responseText = response.text ?: throw Exception("Réponse vide") + + return parseTagsResponse(responseText) + } + + private fun parseTagsResponse(responseText: String): List { + val cleaned = responseText.replace("```json", "").replace("```", "").trim() + val jsonArray = org.json.JSONArray(cleaned) + val tags = mutableListOf() + for (i in 0 until jsonArray.length()) { + tags.add(jsonArray.getString(i)) + } + return tags + } + override suspend fun analyzeUrl(url: String): Result = withContext(Dispatchers.IO) { // Vérifier le cache d'abord if (analysisCache.containsKey(url)) { @@ -144,28 +227,29 @@ class GeminiRepositoryImpl @Inject constructor( private fun buildPrompt(url: String): String { return """ -Analyse cette URL: $url +Rôle: Tu es un assistant expert en classification de contenu web pour l'application Shaarli. -Tu dois analyser le contenu de cette page web et générer les informations suivantes. +Tâche: Analyser les métadonnées de l'URL fournie pour extraire un titre, une description et des tags. -Consignes strictes: -1. Génère un titre pertinent et concis (max 100 caractères) -2. Génère une description résumée du contenu (max 200 mots) -3. Génère une liste de 5 à 15 tags pertinents (en lowercase, sans espaces, utilisez des tirets si nécessaire) -4. Classe le contenu dans UN des types suivants: Article, Video, Tutorial, GitRepository, Other +URL à analyser: $url -Réponds UNIQUEMENT en format JSON strict, sans markdown, sans commentaires, exactement comme ceci: +Règles CRITIQUES de sécurité anti-hallucination : +1. Si tu ne peux pas accéder au contenu réel de la page ou si l'URL est opaque (ex: raccourci, ID vidéo), NE DEVINE PAS. Base-toi uniquement sur les informations explicites dans l'URL. +2. Si c'est une vidéo YouTube, essaie d'extraire le contexte de l'ID si tu le connais, sinon indique "Vérifier le titre" dans le titre. +3. Ne jamais inventer de détails (comme le prix d'une maison) si tu n'as pas la preuve visuelle ou textuelle. + +Format de sortie (JSON Strict uniquement) : { - "title": "Le titre ici", - "description": "La description ici", + "title": "Titre extrait ou 'Titre non trouvé'", + "description": "Résumé factuel (max 200 mots).", "tags": ["tag1", "tag2", "tag3"], - "contentType": "Article" + "contentType": "Un parmi: Article, Video, Tutorial, GitRepository, Other" } -Important: -- Les tags doivent être en lowercase -- Le contentType doit être exactement l'une de ces valeurs: Article, Video, Tutorial, GitRepository, Other -- Ne pas inclure de backticks ou de formatage markdown +Consignes : +- Tags en lowercase, sans accents si possible. +- ContentType strict. +- JSON valide sans Markdown (pas de ```json). """.trimIndent() } diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt index 6051e6f..09c65f2 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -505,6 +505,15 @@ constructor( return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray())) } + override fun getDeadLinksStream(): Flow> { + return Pager( + config = PagingConfig(pageSize = 20, enablePlaceholders = false), + pagingSourceFactory = { linkDao.getDeadLinks() } + ).flow.map { pagingData -> + pagingData.map { it.toDomainModel() } + } + } + private fun parseExistingLink(errorBody: String?): LinkDto? { if (errorBody.isNullOrBlank()) return null return try { @@ -528,7 +537,8 @@ constructor( thumbnailUrl = thumbnailUrl, readingTime = readingTimeMinutes, contentType = contentType.name, - siteName = siteName + siteName = siteName, + isDeadLink = isDeadLink ) } diff --git a/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt b/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt new file mode 100644 index 0000000..270b7c5 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/worker/LinkHealthCheckWorker.kt @@ -0,0 +1,81 @@ +package com.shaarit.data.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.shaarit.data.local.dao.LinkDao +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +@HiltWorker +class LinkHealthCheckWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val linkDao: LinkDao +) : CoroutineWorker(appContext, workerParams) { + + companion object { + const val WORK_NAME = "link_health_check_work" + private const val BATCH_SIZE = 20 + private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24h + private const val TIMEOUT_MS = 10000 + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + // Récupérer les liens qui n'ont pas été vérifiés récemment + // On vérifie ceux qui ont été checkés il y a plus de 24h (ou jamais) + val threshold = System.currentTimeMillis() - CHECK_INTERVAL_MS + val linksToCheck = linkDao.getLinksForHealthCheck(threshold, BATCH_SIZE) + + if (linksToCheck.isEmpty()) { + return@withContext Result.success() + } + + var checkCount = 0 + + linksToCheck.forEach { link -> + // Double vérification pour éviter les notes (déjà filtré par DAO normalement mais sécurité) + if (link.url.startsWith("note://")) { + return@forEach + } + + val isDead = !isUrlAccessible(link.url) + linkDao.updateLinkHealthStatus(link.id, isDead, System.currentTimeMillis()) + checkCount++ + } + + // S'il reste des liens à vérifier, on renvoie retry ou success pour que le prochain run (périodique) s'en charge + // Comme c'est un Worker périodique, Success suffit, il sera relancé plus tard par le scheduler + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + private fun isUrlAccessible(url: String): Boolean { + return try { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "HEAD" + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + connection.instanceFollowRedirects = true + + // On accepte les codes 2xx et 3xx comme "vivants" + // Certains sites bloquent HEAD, on pourrait fallback sur GET mais c'est lourd. + // Pour l'instant on reste simple. + // Parfois 405 Method Not Allowed est retourné pour HEAD, ce qui veut dire que le serveur existe. + val responseCode = connection.responseCode + connection.disconnect() + + responseCode in 200..399 || responseCode == 405 + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/shaarit/domain/model/Models.kt b/app/src/main/java/com/shaarit/domain/model/Models.kt index f8c4969..dd2358b 100644 --- a/app/src/main/java/com/shaarit/domain/model/Models.kt +++ b/app/src/main/java/com/shaarit/domain/model/Models.kt @@ -14,5 +14,6 @@ data class ShaarliLink( val thumbnailUrl: String? = null, val readingTime: Int? = null, val contentType: String? = null, - val siteName: String? = null + val siteName: String? = null, + val isDeadLink: Boolean = false ) diff --git a/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt b/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt index aecb03b..11a9b57 100644 --- a/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt @@ -4,5 +4,6 @@ import com.shaarit.domain.model.AiEnrichmentResult interface GeminiRepository { suspend fun analyzeUrl(url: String): Result + suspend fun generateTags(title: String, description: String): Result> fun isApiKeyConfigured(): Boolean } diff --git a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt index 5977efb..14f4192 100644 --- a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt @@ -59,4 +59,6 @@ interface LinkRepository { suspend fun getAllLinks(): Result> suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List? = null): Result + + fun getDeadLinksStream(): Flow> } diff --git a/app/src/main/java/com/shaarit/domain/usecase/GenerateTagsWithAiUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/GenerateTagsWithAiUseCase.kt new file mode 100644 index 0000000..2f983ae --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/usecase/GenerateTagsWithAiUseCase.kt @@ -0,0 +1,19 @@ +package com.shaarit.domain.usecase + +import com.shaarit.domain.repository.GeminiRepository +import javax.inject.Inject + +class GenerateTagsWithAiUseCase @Inject constructor( + private val geminiRepository: GeminiRepository +) { + suspend operator fun invoke(title: String, description: String): Result> { + if (title.isBlank() && description.isBlank()) { + return Result.failure(Exception("Titre et description vides")) + } + return geminiRepository.generateTags(title, description) + } + + fun isApiKeyConfigured(): Boolean { + return geminiRepository.isApiKeyConfigured() + } +} diff --git a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt new file mode 100644 index 0000000..3dcae3d --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksScreen.kt @@ -0,0 +1,144 @@ +package com.shaarit.presentation.deadlinks + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.shaarit.presentation.feed.ListViewItem +import com.shaarit.ui.theme.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeadLinksScreen( + onNavigateBack: () -> Unit, + onNavigateToEdit: (Int) -> Unit, + viewModel: DeadLinksViewModel = hiltViewModel() +) { + val pagingItems = viewModel.pagedDeadLinks.collectAsLazyPagingItems() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(DeepNavy, DarkNavy) + ) + ) + ) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "Liens inaccessibles", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Retour", + tint = TextPrimary + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DeepNavy.copy(alpha = 0.9f), + titleContentColor = TextPrimary + ) + ) + }, + containerColor = Color.Transparent + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + if (pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = CyanPrimary) + } + } else if (pagingItems.itemCount == 0) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = null, + tint = TextMuted, + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Aucun lien mort détecté", + style = MaterialTheme.typography.titleMedium, + color = TextSecondary + ) + Text( + "Tout semble fonctionner !", + style = MaterialTheme.typography.bodyMedium, + color = TextMuted + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(count = pagingItems.itemCount) { index -> + val link = pagingItems[index] + if (link != null) { + // On utilise ListViewItem en lui passant un indicateur visuel + // Note: Pour l'instant ListViewItem ne gère pas isDeadLink visuellement, + // on va devoir le modifier ou wrapper l'item. + // Le viewmodel gère la suppression. + + ListViewItem( + link = link, + onItemClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) + context.startActivity(intent) + }, + onLinkClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + onEditClick = onNavigateToEdit, + onDeleteClick = { viewModel.deleteLink(link.id) }, + // Pas de tags clickables ici pour simplifier + onTagClick = { }, + onViewClick = { } + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt new file mode 100644 index 0000000..8269e2b --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/deadlinks/DeadLinksViewModel.kt @@ -0,0 +1,28 @@ +package com.shaarit.presentation.deadlinks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.shaarit.domain.model.ShaarliLink +import com.shaarit.domain.repository.LinkRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DeadLinksViewModel @Inject constructor( + private val linkRepository: LinkRepository +) : ViewModel() { + + val pagedDeadLinks: Flow> = + linkRepository.getDeadLinksStream() + .cachedIn(viewModelScope) + + fun deleteLink(id: Int) { + viewModelScope.launch { + linkRepository.deleteLink(id) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt index 423461e..510598b 100644 --- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt @@ -54,6 +54,8 @@ fun EditLinkScreen( val isPrivate by viewModel.isPrivate.collectAsState() val tagSuggestions by viewModel.tagSuggestions.collectAsState() val contentType by viewModel.contentType.collectAsState() + val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState() + val aiTagsState by viewModel.aiTagsState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val focusManager = LocalFocusManager.current @@ -94,6 +96,13 @@ fun EditLinkScreen( } } + LaunchedEffect(aiTagsState) { + if (aiTagsState is AiEnrichmentState.Success) { + snackbarHostState.showSnackbar("✨ Tags IA générés !") + viewModel.resetAiTagsState() + } + } + Box( modifier = Modifier .fillMaxSize() @@ -214,6 +223,32 @@ fun EditLinkScreen( textStyle = MaterialTheme.typography.bodyMedium ) + // Classic Fetch Button + IconButton( + onClick = { viewModel.fetchMetadataClassic() }, + enabled = url.isNotBlank() && !isExtractingMetadata, + modifier = Modifier + .size(48.dp) // Même taille que AiMagicButton + .background( + color = SurfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp) + ) + ) { + if (isExtractingMetadata) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = TextSecondary, + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Default.Refresh, + contentDescription = "Récupérer infos classique", + tint = TextSecondary + ) + } + } + // AI Magic Button AiMagicButton( onClick = { viewModel.analyzeUrlWithAi() }, @@ -448,6 +483,35 @@ fun EditLinkScreen( tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted ) } + + // Bouton Tags IA + IconButton( + onClick = { viewModel.generateTagsWithAi() }, + enabled = aiTagsState !is AiEnrichmentState.Loading, + modifier = Modifier + .size(40.dp) + .background( + color = if (aiTagsState is AiEnrichmentState.Loading) + SurfaceVariant.copy(alpha = 0.5f) + else + CyanPrimary.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + ) { + if (aiTagsState is AiEnrichmentState.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = CyanPrimary, + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Outlined.AutoAwesome, + contentDescription = "Générer tags IA", + tint = CyanPrimary + ) + } + } } // Tag suggestions diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt index d96bd22..5fc7abc 100644 --- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt @@ -8,6 +8,8 @@ import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase import com.shaarit.presentation.add.ContentType +import com.shaarit.data.metadata.LinkMetadataExtractor +import com.shaarit.domain.usecase.GenerateTagsWithAiUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,6 +24,8 @@ class EditLinkViewModel constructor( private val linkRepository: LinkRepository, private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase, + private val generateTagsWithAiUseCase: GenerateTagsWithAiUseCase, + private val metadataExtractor: LinkMetadataExtractor, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -57,6 +61,12 @@ constructor( private val _aiEnrichmentState = MutableStateFlow(AiEnrichmentState.Idle) val aiEnrichmentState = _aiEnrichmentState.asStateFlow() + private val _aiTagsState = MutableStateFlow(AiEnrichmentState.Idle) + val aiTagsState = _aiTagsState.asStateFlow() + + private val _isExtractingMetadata = MutableStateFlow(false) + val isExtractingMetadata = _isExtractingMetadata.asStateFlow() + private val _aiErrorMessage = MutableSharedFlow() val aiErrorMessage = _aiErrorMessage.asSharedFlow() @@ -156,6 +166,82 @@ constructor( addTag(_newTagInput.value) } + fun fetchMetadataClassic() { + val currentUrl = url.value + if (currentUrl.isBlank()) { + viewModelScope.launch { + _aiErrorMessage.emit("Veuillez d'abord entrer une URL") + } + return + } + + viewModelScope.launch { + _isExtractingMetadata.value = true + try { + val metadata = metadataExtractor.extract(currentUrl) + + // Appliquer les métadonnées (Emoji 🔖 uniquement sur la description) + if (!metadata.title.isNullOrBlank()) { + title.value = metadata.title + } + + if (!metadata.description.isNullOrBlank()) { + description.value = "🔖 ${metadata.description}" + } + + // Pas de tags auto en mode classique, sauf si on veut extraire ceux du site (pas demandé spécifiquement mais logique) + // Le user a demandé "fetch information de l'URL", donc titre et description. + + _aiErrorMessage.emit("Métadonnées récupérées (Mode Classique)") + } catch (e: Exception) { + _aiErrorMessage.emit("Erreur récupération classique: ${e.message}") + } finally { + _isExtractingMetadata.value = false + } + } + } + + fun generateTagsWithAi() { + val currentTitle = title.value.replace("✨ ", "").replace("🔖 ", "") + val currentDescription = description.value.replace("✨ ", "").replace("🔖 ", "") + + if (currentTitle.isBlank() && currentDescription.isBlank()) { + viewModelScope.launch { + _aiErrorMessage.emit("Titre ou description requis pour générer des tags") + } + return + } + + if (!generateTagsWithAiUseCase.isApiKeyConfigured()) { + viewModelScope.launch { + _aiErrorMessage.emit("Clé API Gemini non configurée.") + } + return + } + + viewModelScope.launch { + _aiTagsState.value = AiEnrichmentState.Loading + + generateTagsWithAiUseCase(currentTitle, currentDescription) + .onSuccess { tags -> + val currentTags = _selectedTags.value.toMutableSet() + tags.forEach { tag -> + val cleanTag = tag.trim().lowercase() + if (cleanTag.isNotBlank()) { + currentTags.add(cleanTag) + } + } + _selectedTags.value = currentTags.toList() + _aiTagsState.value = AiEnrichmentState.Success + _aiErrorMessage.emit("Tags générés par IA !") + } + .onFailure { error -> + _aiTagsState.value = AiEnrichmentState.Idle + _aiErrorMessage.emit("Erreur tags IA: ${error.message}") + } + } + } + fun analyzeUrlWithAi() { val currentUrl = url.value if (currentUrl.isBlank()) { @@ -188,8 +274,9 @@ constructor( } private fun applyAiEnrichment(result: AiEnrichmentResult) { + // Ajout de l'émoji ✨ au début (Uniquement description) title.value = result.title - description.value = result.description + description.value = "✨ ${result.description}" // Add AI-generated tags to existing tags (without duplicates) val currentTags = _selectedTags.value.toMutableSet() @@ -202,6 +289,10 @@ constructor( _selectedTags.value = currentTags.toList() } + fun resetAiTagsState() { + _aiTagsState.value = AiEnrichmentState.Idle + } + fun resetAiEnrichmentState() { _aiEnrichmentState.value = AiEnrichmentState.Idle } diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index 5f405b6..86a4575 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -272,6 +272,7 @@ fun FeedScreen( onNavigateToSettings: () -> Unit = {}, onNavigateToRandom: () -> Unit = {}, onNavigateToHelp: () -> Unit = {}, + onNavigateToDeadLinks: () -> Unit = {}, initialTagFilter: String? = null, initialCollectionId: Long? = null, viewModel: FeedViewModel = hiltViewModel() @@ -433,6 +434,15 @@ fun FeedScreen( onNavigateToHelp() } ) + + DrawerNavigationItem( + icon = Icons.Default.BrokenImage, + label = "Liens inaccessibles", + onClick = { + scope.launch { drawerState.close() } + onNavigateToDeadLinks() + } + ) } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt index 21c0c7b..12a1785 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.window.DialogProperties @@ -88,13 +89,24 @@ fun ListViewItem( overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) - Text( - text = link.url, - style = MaterialTheme.typography.bodySmall, - color = TealSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + Row(verticalAlignment = Alignment.CenterVertically) { + if (link.isDeadLink) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = "Lien mort", + tint = ErrorRed, + modifier = Modifier.size(16.dp).padding(end = 4.dp) + ) + } + Text( + text = link.url, + style = MaterialTheme.typography.bodySmall, + color = if (link.isDeadLink) ErrorRed else TealSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } Row { @@ -246,11 +258,20 @@ fun GridViewItem( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { + if (link.isDeadLink) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = "Lien mort", + tint = ErrorRed, + modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically) + ) + } + Text( text = link.title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, - color = CyanPrimary, + color = if (link.isDeadLink) ErrorRed else CyanPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -467,14 +488,24 @@ fun CompactViewItem( } Column(modifier = Modifier.weight(1f)) { - Text( - text = link.title, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = CyanPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (link.isDeadLink) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = "Lien mort", + tint = ErrorRed, + modifier = Modifier.size(14.dp).padding(end = 4.dp) + ) + } + Text( + text = link.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (link.isDeadLink) ErrorRed else CyanPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index 6465271..1db76d8 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -42,6 +42,7 @@ sealed class Screen(val route: String) { object Dashboard : Screen("dashboard") object Settings : Screen("settings") object Help : Screen("help") + object DeadLinks : Screen("dead_links") } @Composable @@ -125,6 +126,7 @@ fun AppNavGraph( onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, onNavigateToRandom = { }, onNavigateToHelp = { navController.navigate(Screen.Help.route) }, + onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) }, initialTagFilter = tag, initialCollectionId = collectionId ) @@ -256,5 +258,16 @@ fun AppNavGraph( onNavigateBack = { navController.popBackStack() } ) } + + composable( + route = Screen.DeadLinks.route + ) { + com.shaarit.presentation.deadlinks.DeadLinksScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { linkId -> + navController.navigate(Screen.Edit.createRoute(linkId)) + } + ) + } } }