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
This commit is contained in:
Bruno Charest 2026-01-31 13:19:53 -05:00
parent f88b7ffad3
commit 02c7300c3b
17 changed files with 661 additions and 35 deletions

View File

@ -3,7 +3,12 @@ package com.shaarit
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration 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 dagger.hilt.android.HiltAndroidApp
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
@ -13,4 +18,21 @@ class ShaarItApp : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build() get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
override fun onCreate() {
super.onCreate()
setupHealthCheckWorker()
}
private fun setupHealthCheckWorker() {
val healthCheckRequest = PeriodicWorkRequestBuilder<LinkHealthCheckWorker>(
12, TimeUnit.HOURS // Run twice a day
).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
LinkHealthCheckWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
healthCheckRequest
)
}
} }

View File

@ -96,6 +96,25 @@ interface LinkDao {
""") """)
suspend fun countLinksBySearch(query: String): Int suspend fun countLinksBySearch(query: String): Int
@Query("""
SELECT * FROM links
WHERE is_dead_link = 1
ORDER BY last_health_check DESC
""")
fun getDeadLinks(): PagingSource<Int, LinkEntity>
@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<LinkEntity>
@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 ====== // ====== Filtres temporels ======
@Query(""" @Query("""

View File

@ -69,7 +69,13 @@ data class LinkEntity(
val siteName: String? = null, val siteName: String? = null,
@ColumnInfo(name = "excerpt") @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
) )
/** /**

View File

@ -20,6 +20,9 @@ class GeminiRepositoryImpl @Inject constructor(
private val tokenManager: TokenManager private val tokenManager: TokenManager
) : GeminiRepository { ) : GeminiRepository {
// Cache pour les tags
private val tagsCache = mutableMapOf<String, List<String>>()
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session // Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>() private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
@ -27,6 +30,86 @@ class GeminiRepositoryImpl @Inject constructor(
return !tokenManager.getGeminiApiKey().isNullOrBlank() return !tokenManager.getGeminiApiKey().isNullOrBlank()
} }
override suspend fun generateTags(title: String, description: String): Result<List<String>> = 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<String> {
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<String> {
val cleaned = responseText.replace("```json", "").replace("```", "").trim()
val jsonArray = org.json.JSONArray(cleaned)
val tags = mutableListOf<String>()
for (i in 0 until jsonArray.length()) {
tags.add(jsonArray.getString(i))
}
return tags
}
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) { override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
// Vérifier le cache d'abord // Vérifier le cache d'abord
if (analysisCache.containsKey(url)) { if (analysisCache.containsKey(url)) {
@ -144,28 +227,29 @@ class GeminiRepositoryImpl @Inject constructor(
private fun buildPrompt(url: String): String { private fun buildPrompt(url: String): String {
return """ 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: URL à analyser: $url
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
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", "title": "Titre extrait ou 'Titre non trouvé'",
"description": "La description ici", "description": "Résumé factuel (max 200 mots).",
"tags": ["tag1", "tag2", "tag3"], "tags": ["tag1", "tag2", "tag3"],
"contentType": "Article" "contentType": "Un parmi: Article, Video, Tutorial, GitRepository, Other"
} }
Important: Consignes :
- Les tags doivent être en lowercase - Tags en lowercase, sans accents si possible.
- Le contentType doit être exactement l'une de ces valeurs: Article, Video, Tutorial, GitRepository, Other - ContentType strict.
- Ne pas inclure de backticks ou de formatage markdown - JSON valide sans Markdown (pas de ```json).
""".trimIndent() """.trimIndent()
} }

View File

@ -505,6 +505,15 @@ constructor(
return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray())) return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray()))
} }
override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { linkDao.getDeadLinks() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
}
}
private fun parseExistingLink(errorBody: String?): LinkDto? { private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null if (errorBody.isNullOrBlank()) return null
return try { return try {
@ -528,7 +537,8 @@ constructor(
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes, readingTime = readingTimeMinutes,
contentType = contentType.name, contentType = contentType.name,
siteName = siteName siteName = siteName,
isDeadLink = isDeadLink
) )
} }

View File

@ -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
}
}
}

View File

@ -14,5 +14,6 @@ data class ShaarliLink(
val thumbnailUrl: String? = null, val thumbnailUrl: String? = null,
val readingTime: Int? = null, val readingTime: Int? = null,
val contentType: String? = null, val contentType: String? = null,
val siteName: String? = null val siteName: String? = null,
val isDeadLink: Boolean = false
) )

View File

@ -4,5 +4,6 @@ import com.shaarit.domain.model.AiEnrichmentResult
interface GeminiRepository { interface GeminiRepository {
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
suspend fun generateTags(title: String, description: String): Result<List<String>>
fun isApiKeyConfigured(): Boolean fun isApiKeyConfigured(): Boolean
} }

View File

@ -59,4 +59,6 @@ interface LinkRepository {
suspend fun getAllLinks(): Result<List<ShaarliLink>> suspend fun getAllLinks(): Result<List<ShaarliLink>>
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit> suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>>
} }

View File

@ -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<List<String>> {
if (title.isBlank() && description.isBlank()) {
return Result.failure(Exception("Titre et description vides"))
}
return geminiRepository.generateTags(title, description)
}
fun isApiKeyConfigured(): Boolean {
return geminiRepository.isApiKeyConfigured()
}
}

View File

@ -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 = { }
)
}
}
}
}
}
}
}
}

View File

@ -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<PagingData<ShaarliLink>> =
linkRepository.getDeadLinksStream()
.cachedIn(viewModelScope)
fun deleteLink(id: Int) {
viewModelScope.launch {
linkRepository.deleteLink(id)
}
}
}

View File

@ -54,6 +54,8 @@ fun EditLinkScreen(
val isPrivate by viewModel.isPrivate.collectAsState() val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState() val tagSuggestions by viewModel.tagSuggestions.collectAsState()
val contentType by viewModel.contentType.collectAsState() val contentType by viewModel.contentType.collectAsState()
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
val aiTagsState by viewModel.aiTagsState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -214,6 +223,32 @@ fun EditLinkScreen(
textStyle = MaterialTheme.typography.bodyMedium 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 // AI Magic Button
AiMagicButton( AiMagicButton(
onClick = { viewModel.analyzeUrlWithAi() }, onClick = { viewModel.analyzeUrlWithAi() },
@ -448,6 +483,35 @@ fun EditLinkScreen(
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted 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 // Tag suggestions

View File

@ -8,6 +8,8 @@ import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase
import com.shaarit.presentation.add.ContentType 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -22,6 +24,8 @@ class EditLinkViewModel
constructor( constructor(
private val linkRepository: LinkRepository, private val linkRepository: LinkRepository,
private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase, private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase,
private val generateTagsWithAiUseCase: GenerateTagsWithAiUseCase,
private val metadataExtractor: LinkMetadataExtractor,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -57,6 +61,12 @@ constructor(
private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle) private val _aiEnrichmentState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiEnrichmentState = _aiEnrichmentState.asStateFlow() val aiEnrichmentState = _aiEnrichmentState.asStateFlow()
private val _aiTagsState = MutableStateFlow<AiEnrichmentState>(AiEnrichmentState.Idle)
val aiTagsState = _aiTagsState.asStateFlow()
private val _isExtractingMetadata = MutableStateFlow(false)
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
private val _aiErrorMessage = MutableSharedFlow<String>() private val _aiErrorMessage = MutableSharedFlow<String>()
val aiErrorMessage = _aiErrorMessage.asSharedFlow() val aiErrorMessage = _aiErrorMessage.asSharedFlow()
@ -156,6 +166,82 @@ constructor(
addTag(_newTagInput.value) 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() { fun analyzeUrlWithAi() {
val currentUrl = url.value val currentUrl = url.value
if (currentUrl.isBlank()) { if (currentUrl.isBlank()) {
@ -188,8 +274,9 @@ constructor(
} }
private fun applyAiEnrichment(result: AiEnrichmentResult) { private fun applyAiEnrichment(result: AiEnrichmentResult) {
// Ajout de l'émoji ✨ au début (Uniquement description)
title.value = result.title title.value = result.title
description.value = result.description description.value = "${result.description}"
// Add AI-generated tags to existing tags (without duplicates) // Add AI-generated tags to existing tags (without duplicates)
val currentTags = _selectedTags.value.toMutableSet() val currentTags = _selectedTags.value.toMutableSet()
@ -202,6 +289,10 @@ constructor(
_selectedTags.value = currentTags.toList() _selectedTags.value = currentTags.toList()
} }
fun resetAiTagsState() {
_aiTagsState.value = AiEnrichmentState.Idle
}
fun resetAiEnrichmentState() { fun resetAiEnrichmentState() {
_aiEnrichmentState.value = AiEnrichmentState.Idle _aiEnrichmentState.value = AiEnrichmentState.Idle
} }

View File

@ -272,6 +272,7 @@ fun FeedScreen(
onNavigateToSettings: () -> Unit = {}, onNavigateToSettings: () -> Unit = {},
onNavigateToRandom: () -> Unit = {}, onNavigateToRandom: () -> Unit = {},
onNavigateToHelp: () -> Unit = {}, onNavigateToHelp: () -> Unit = {},
onNavigateToDeadLinks: () -> Unit = {},
initialTagFilter: String? = null, initialTagFilter: String? = null,
initialCollectionId: Long? = null, initialCollectionId: Long? = null,
viewModel: FeedViewModel = hiltViewModel() viewModel: FeedViewModel = hiltViewModel()
@ -433,6 +434,15 @@ fun FeedScreen(
onNavigateToHelp() onNavigateToHelp()
} }
) )
DrawerNavigationItem(
icon = Icons.Default.BrokenImage,
label = "Liens inaccessibles",
onClick = {
scope.launch { drawerState.close() }
onNavigateToDeadLinks()
}
)
} }
} }

View File

@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@ -88,14 +89,25 @@ fun ListViewItem(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
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(
text = link.url, text = link.url,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = TealSecondary, color = if (link.isDeadLink) ErrorRed else TealSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
}
Row { Row {
if (selectionMode) { if (selectionMode) {
@ -246,11 +258,20 @@ fun GridViewItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() 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(
text = link.title, text = link.title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = CyanPrimary, color = if (link.isDeadLink) ErrorRed else CyanPrimary,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@ -467,14 +488,24 @@ fun CompactViewItem(
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
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(
text = link.title, text = link.title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = CyanPrimary, color = if (link.isDeadLink) ErrorRed else CyanPrimary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),

View File

@ -42,6 +42,7 @@ sealed class Screen(val route: String) {
object Dashboard : Screen("dashboard") object Dashboard : Screen("dashboard")
object Settings : Screen("settings") object Settings : Screen("settings")
object Help : Screen("help") object Help : Screen("help")
object DeadLinks : Screen("dead_links")
} }
@Composable @Composable
@ -125,6 +126,7 @@ fun AppNavGraph(
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { }, onNavigateToRandom = { },
onNavigateToHelp = { navController.navigate(Screen.Help.route) }, onNavigateToHelp = { navController.navigate(Screen.Help.route) },
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
initialTagFilter = tag, initialTagFilter = tag,
initialCollectionId = collectionId initialCollectionId = collectionId
) )
@ -256,5 +258,16 @@ fun AppNavGraph(
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
composable(
route = Screen.DeadLinks.route
) {
com.shaarit.presentation.deadlinks.DeadLinksScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { linkId ->
navController.navigate(Screen.Edit.createRoute(linkId))
}
)
}
} }
} }