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:
parent
f88b7ffad3
commit
02c7300c3b
@ -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<LinkHealthCheckWorker>(
|
||||
12, TimeUnit.HOURS // Run twice a day
|
||||
).build()
|
||||
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
LinkHealthCheckWorker.WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
healthCheckRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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 ======
|
||||
|
||||
@Query("""
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -20,6 +20,9 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
private val tokenManager: TokenManager
|
||||
) : 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
|
||||
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
|
||||
|
||||
@ -27,6 +30,86 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
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) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
||||
@ -505,6 +505,15 @@ constructor(
|
||||
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? {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -4,5 +4,6 @@ import com.shaarit.domain.model.AiEnrichmentResult
|
||||
|
||||
interface GeminiRepository {
|
||||
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
|
||||
suspend fun generateTags(title: String, description: String): Result<List<String>>
|
||||
fun isApiKeyConfigured(): Boolean
|
||||
}
|
||||
|
||||
@ -59,4 +59,6 @@ interface LinkRepository {
|
||||
suspend fun getAllLinks(): Result<List<ShaarliLink>>
|
||||
|
||||
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
|
||||
|
||||
fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>>
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>(AiEnrichmentState.Idle)
|
||||
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>()
|
||||
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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,14 +89,25 @@ fun ListViewItem(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
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 = link.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TealSecondary,
|
||||
color = if (link.isDeadLink) ErrorRed else TealSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
if (selectionMode) {
|
||||
@ -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)) {
|
||||
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 = CyanPrimary,
|
||||
color = if (link.isDeadLink) ErrorRed else CyanPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user