diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-25 00:55:59 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-25 00:55:59 +0000 |
| commit | f202c603259c40c4d21a4385c9f69a3efca87acc (patch) | |
| tree | 35718f1d1a48f47c2d63f239121a71c96c076d2d | |
| parent | 439ad1a4f8b6a492bbd54f239462dc38b874cd4c (diff) | |
Text-only bottom bar, re-extract action, fix YouTube thumbnails, verify Stash creds before saving
- Bottom bar: Previous/Keep unread/Next are now plain text labels, no icons
- Add "Re-download and re-extract" to the overflow menu (ReaderScreen and
LinkReaderScreen) to force a fresh fetch + Readability pass on demand,
overwriting whatever's cached
- Fix a real compile bug in the previous commit's reader-mode-off handler
(passed null into a non-null setExtractedContent) via a proper
clearExtractedContent() DAO query
- YouTube embeds: fixed the broken-image-glyph bug two ways -- (1) an
11-char video-ID regex was accidentally matching a playlist embed's
literal "videoseries" placeholder path, building a thumbnail URL for a
video that doesn't exist, and (2) Readability's link-density cleanup was
discarding the replacement entirely (a bare <a><img></a> reads as an
ad/share unit). Restructured as a <figure>/<figcaption> pair -- the same
shape a normal captioned photo takes -- confirmed live this survives
extraction intact
- Stash settings: API key field is now a password field with a show/hide
toggle, and "Verify and save" makes a real authenticated request before
persisting anything, showing why it failed instead of silently saving
bad credentials
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
7 files changed, 189 insertions, 40 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt index 0b763e1..1f442e9 100644 --- a/app/src/main/java/org/terst/feedreader/data/Daos.kt +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -136,6 +136,9 @@ interface ArticleDao { @Query("UPDATE articles SET extractedContent = :content WHERE id = :id") suspend fun setExtractedContent(id: String, content: String) + @Query("UPDATE articles SET extractedContent = NULL WHERE id = :id") + suspend fun clearExtractedContent(id: String) + @Query("UPDATE articles SET isSaved = :saved WHERE id = :id") suspend fun setSaved(id: String, saved: Boolean) diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt index 3cb4741..9a96e63 100644 --- a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt +++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt @@ -180,6 +180,17 @@ object ReaderExtractor { } } + // Some sites lazy-load the embed iframe itself, not just images -- + // leaving `src` blank or a generic loader URL until their own script + // runs, which never happens here. Resolve iframe data-src first (same + // idea as the image lazy-load fix above) so the YouTube detection below + // has a real URL to work with instead of nothing. + var lazyFrames = document.querySelectorAll('iframe[data-src], iframe[data-lazy-src]'); + for (var i = 0; i < lazyFrames.length; i++) { + var lazyFrameSrc = lazyFrames[i].getAttribute('data-src') || lazyFrames[i].getAttribute('data-lazy-src'); + if (lazyFrameSrc) lazyFrames[i].setAttribute('src', lazyFrameSrc); + } + // A YouTube iframe embed is dead weight here -- JS is off in the WebView // that actually displays this content, so the real embedded player could // never initialize anyway (just a blank/broken box). Swap it for a @@ -193,20 +204,47 @@ object ReaderExtractor { ); for (var i = 0; i < iframes.length; i++) { var frame = iframes[i]; - var match = frame.getAttribute('src').match(/embed\/([a-zA-Z0-9_-]+)/); - if (!match) continue; - var videoId = match[1]; + var frameSrc = frame.getAttribute('src') || ''; + // A real video ID is always exactly 11 chars -- a playlist/live + // embed's path is literally ".../embed/videoseries?list=...", which + // (coincidentally exactly 11 characters itself) previously slipped + // past a length-only check and built a thumbnail URL for a video + // that doesn't exist (confirmed live: rendered as a broken-image + // glyph, not a still frame -- exactly what was reported). + var match = frameSrc.match(/embed\/([a-zA-Z0-9_-]{11})(?:[/?]|$)/); + if (match && match[1] === 'videoseries') match = null; + // Neither a class NOR an inline style survives here -- Readability + // strips both as part of its own cleanup. A bare <a><img></a> with + // no surrounding text didn't survive either, for a different reason: + // Readability's link-density cleanup treats a node that's almost + // entirely inside an <a> as a probable ad/share-button unit and + // discards it (confirmed live: the iframe was there, the transform + // ran, and the result still vanished from the final extracted + // content). A <figure>/<figcaption> pair -- the exact shape a normal + // captioned photo takes, which reliably survives elsewhere in this + // pipeline -- with the caption text OUTSIDE the link keeps the + // node's link-density low enough to read as real content. + var figure = document.createElement('figure'); var link = document.createElement('a'); - link.className = 'yt-embed'; - link.href = 'https://www.youtube.com/watch?v=' + videoId; - var thumb = document.createElement('img'); - thumb.src = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg'; - thumb.alt = 'YouTube video'; - var play = document.createElement('span'); - play.className = 'yt-play'; - link.appendChild(thumb); - link.appendChild(play); - frame.parentNode.replaceChild(link, frame); + figure.appendChild(link); + if (match) { + var videoId = match[1]; + link.href = 'https://www.youtube.com/watch?v=' + videoId; + var thumb = document.createElement('img'); + thumb.src = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg'; + thumb.alt = 'YouTube video'; + link.appendChild(thumb); + var caption = document.createElement('figcaption'); + caption.textContent = '▶ Watch on YouTube'; + figure.appendChild(caption); + } else { + // No confident single video ID (a playlist, a live stream, or + // lazy-load resolution above still came up empty) -- a plain + // link beats a broken thumbnail or a dead iframe. + link.href = frameSrc || 'https://www.youtube.com/'; + link.textContent = 'Watch on YouTube'; + } + frame.parentNode.replaceChild(figure, frame); } // A news/blog article page's <header> conventionally holds everything diff --git a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt index 28cc2b3..b613d66 100644 --- a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt +++ b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt @@ -36,6 +36,11 @@ sealed class StashSaveResult { data class Failed(val message: String) : StashSaveResult() } +sealed class StashVerifyResult { + object Valid : StashVerifyResult() + data class Invalid(val message: String) : StashVerifyResult() +} + /** * Client for the parts of Stash's API this app needs: POST /artifacts to * archive an article. Uses sync=true so content_md comes back in the same @@ -48,6 +53,30 @@ class StashClient( private val baseUrl: String, private val apiKey: String ) { + // A cheap authenticated GET against the same collection /artifacts POSTs to, rather than + // a dedicated /me or /health endpoint this API doesn't document -- reads are safe/free of + // side effects, unlike actually creating an artifact just to test the key works. + suspend fun verifyCredentials(): StashVerifyResult = withContext(Dispatchers.IO) { + if (baseUrl.isBlank() || apiKey.isBlank()) { + return@withContext StashVerifyResult.Invalid("Base URL and API key are both required") + } + val request = Request.Builder() + .url("$baseUrl/artifacts") + .header("X-API-Key", apiKey) + .get() + .build() + + runCatching { + client.newCall(request).execute().use { response -> + when { + response.isSuccessful -> StashVerifyResult.Valid + response.code == 401 || response.code == 403 -> StashVerifyResult.Invalid("Rejected: API key not accepted") + else -> StashVerifyResult.Invalid("HTTP ${response.code}") + } + } + }.getOrElse { StashVerifyResult.Invalid(it.message ?: "Couldn't reach that URL") } + } + suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) { if (baseUrl.isBlank() || apiKey.isBlank()) { return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)") diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt index c001b69..c29a585 100644 --- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt @@ -80,6 +80,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un var extractionFailed by remember(url) { mutableStateOf(false) } var isReaderMode by remember(url) { mutableStateOf(true) } var isSaving by remember(url) { mutableStateOf(false) } + var isReextracting by remember(url) { mutableStateOf(false) } var moreMenuExpanded by remember(url) { mutableStateOf(false) } var longPressedLink by remember(url) { mutableStateOf<String?>(null) } @@ -194,6 +195,29 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un } DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) { DropdownMenuItem( + enabled = !isReextracting, + text = { Text("Re-download and re-extract") }, + onClick = { + moreMenuExpanded = false + scope.launch { + isReextracting = true + val extracted = ReaderExtractor.extract(context, url) + isReextracting = false + val content = extracted?.content + if (content != null) { + extractedTitle = extracted.title + extractedContent = content + extractionFailed = false + isReaderMode = true + db.articleDao().setExtractedContent(articleId, content) + snackbarHostState.showSnackbar("Re-extracted") + } else { + snackbarHostState.showSnackbar("Re-extraction failed") + } + } + } + ) + DropdownMenuItem( text = { Text("Find another source") }, onClick = { moreMenuExpanded = false diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt index b7f6018..06227b7 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -24,10 +24,7 @@ import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.NavigateBefore -import androidx.compose.material.icons.filled.NavigateNext import androidx.compose.material.icons.filled.OpenInBrowser -import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar @@ -252,7 +249,7 @@ private fun ReaderContent( } else { isReaderMode = false readerContent = null - db.articleDao().setExtractedContent(articleId, null) + db.articleDao().clearExtractedContent(articleId) snackbarHostState.showSnackbar("Reader mode disabled for $feedLabel") } } @@ -393,6 +390,32 @@ private fun ReaderContent( onOpenBrowser(a.link) } ) + // Forces a fresh fetch + Readability pass, overwriting whatever's + // cached -- for when extraction came back thin, stale, or broken + // (e.g. a YouTube thumbnail that failed to resolve) and simply hasn't + // been retried since whatever caused that. + DropdownMenuItem( + enabled = !isExtracting, + text = { Text("Re-download and re-extract") }, + onClick = { + moreMenuExpanded = false + val a = currentArticle ?: return@DropdownMenuItem + scope.launch { + isExtracting = true + val extracted = ReaderExtractor.extract(context, a.link) + isExtracting = false + val content = extracted?.content + if (content != null) { + readerContent = content + isReaderMode = true + db.articleDao().setExtractedContent(a.id, content) + snackbarHostState.showSnackbar("Re-extracted") + } else { + snackbarHostState.showSnackbar("Re-extraction failed") + } + } + } + ) DropdownMenuItem( text = { Text("Report bug") }, onClick = { @@ -406,26 +429,16 @@ private fun ReaderContent( }, bottomBar = { BottomAppBar { - TextButton( - onClick = onPrevious, - enabled = hasPrevious, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.NavigateBefore, contentDescription = null) + TextButton(onClick = onPrevious, enabled = hasPrevious, modifier = Modifier.weight(1f)) { Text("Previous") } // Advances like Next, but skips the implicit "mark read" that moving on // normally carries -- for "I looked, but I'm not done with this one yet." - IconButton(onClick = onNextKeepUnread, enabled = hasNext) { - Icon(Icons.Default.SkipNext, contentDescription = "Next, keep unread") + TextButton(onClick = onNextKeepUnread, enabled = hasNext, modifier = Modifier.weight(1f)) { + Text("Keep unread") } - TextButton( - onClick = onNext, - enabled = hasNext, - modifier = Modifier.weight(1f) - ) { + TextButton(onClick = onNext, enabled = hasNext, modifier = Modifier.weight(1f)) { Text("Next") - Icon(Icons.Default.NavigateNext, contentDescription = null) } } }, diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index b984d1d..1fd2411 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -135,12 +135,6 @@ fun wrapReaderHtml(bodyContent: String, title: String? = null): String = """ explicit width/height was deliberately sized by the page itself, so leave those alone -- just cap the ones that would otherwise size themselves. */ svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; } - /* A tap-to-open thumbnail standing in for a YouTube embed (see ReaderExtractor) -- - JS is off here, so the real embedded player could never have worked anyway. */ - .yt-embed { position: relative; display: block; margin: 0.5em 0; } - .yt-embed .yt-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 0; height: 0; border-style: solid; border-width: 18px 0 18px 30px; - border-color: transparent transparent transparent rgba(255,255,255,0.85); } </style></head> <body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}$bodyContent</body></html> """.trimIndent() diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt index b7897f4..3bd6e3e 100644 --- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -13,7 +13,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -36,12 +39,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.FilterAction import org.terst.feedreader.data.FilterRuleEntity +import org.terst.feedreader.data.Net import org.terst.feedreader.data.Settings +import org.terst.feedreader.stash.StashClient +import org.terst.feedreader.stash.StashVerifyResult @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,6 +61,9 @@ fun SettingsScreen(onBack: () -> Unit) { var stashUrl by remember { mutableStateOf("") } var stashKey by remember { mutableStateOf("") } + var stashKeyVisible by remember { mutableStateOf(false) } + var isVerifyingStash by remember { mutableStateOf(false) } + var stashStatus by remember { mutableStateOf<String?>(null) } LaunchedEffect(Unit) { stashUrl = settings.stashBaseUrlNow() stashKey = settings.stashApiKeyNow() @@ -105,15 +116,52 @@ fun SettingsScreen(onBack: () -> Unit) { Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = stashKey, - onValueChange = { stashKey = it }, + onValueChange = { stashKey = it; stashStatus = null }, label = { Text("API key") }, + singleLine = true, + visualTransformation = if (stashKeyVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { stashKeyVisible = !stashKeyVisible }) { + Icon( + if (stashKeyVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (stashKeyVisible) "Hide API key" else "Show API key" + ) + } + }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) Button( - onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } }, + enabled = !isVerifyingStash, + onClick = { + scope.launch { + isVerifyingStash = true + stashStatus = null + val client = StashClient(Net.client, stashUrl.trim(), stashKey.trim()) + when (val result = client.verifyCredentials()) { + is StashVerifyResult.Valid -> { + settings.setStash(stashUrl.trim(), stashKey.trim()) + stashStatus = "Verified and saved" + } + is StashVerifyResult.Invalid -> { + stashStatus = result.message + } + } + isVerifyingStash = false + } + }, modifier = Modifier.fillMaxWidth() - ) { Text("Save") } + ) { + if (isVerifyingStash) { + CircularProgressIndicator(modifier = Modifier.height(20.dp).width(20.dp)) + } else { + Text("Verify and save") + } + } + stashStatus?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall) + } } HorizontalDivider() } |
