diff --git a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt index dfc7393..4731e8a 100644 --- a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -9,81 +9,7 @@ import com.swoosh.microblog.data.model.LinkPreview object MobiledocBuilder { fun build(text: String, linkPreview: LinkPreview?): String { - return build(text, linkPreview?.url, linkPreview?.title, linkPreview?.description) - } - - fun build( - text: String, - linkUrl: String?, - linkTitle: String?, - linkDescription: String? - ): String { - return build(text, emptyList(), linkUrl, linkTitle, linkDescription, null) - } - - /** - * Build with a single image URL and optional alt text (HEAD's 6-param overload). - */ - fun build( - text: String, - linkUrl: String?, - linkTitle: String?, - linkDescription: String?, - imageUrl: String?, - imageAlt: String? - ): String { - val imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList() - return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt) - } - - /** - * Build with multiple image URLs but no alt text (multi-image branch's 5-param overload). - */ - fun build( - text: String, - imageUrls: List, - linkUrl: String?, - linkTitle: String?, - linkDescription: String? - ): String { - return build(text, imageUrls, linkUrl, linkTitle, linkDescription, null) - } - - /** - * Build with multiple images, alt text, and optional video/audio. - * Delegates to the full implementation. - */ - fun build( - text: String, - imageUrls: List, - linkUrl: String?, - linkTitle: String?, - linkDescription: String?, - imageAlt: String? - ): String { - return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt, null, null) - } - - /** - * Build with images, video, audio (no file). - * Delegates to the full implementation. - */ - fun build( - text: String, - imageUrls: List, - linkUrl: String?, - linkTitle: String?, - linkDescription: String?, - imageAlt: String?, - videoUrl: String?, - audioUrl: String? - ): String { - return build( - text = text, imageUrls = imageUrls, - linkUrl = linkUrl, linkTitle = linkTitle, linkDescription = linkDescription, - imageAlt = imageAlt, videoUrl = videoUrl, audioUrl = audioUrl, - fileUrl = null, fileName = null, fileSize = 0 - ) + return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = linkPreview?.description) } /** @@ -94,11 +20,11 @@ object MobiledocBuilder { */ fun build( text: String, - imageUrls: List, - linkUrl: String?, - linkTitle: String?, - linkDescription: String?, - imageAlt: String?, + imageUrls: List = emptyList(), + linkUrl: String? = null, + linkTitle: String? = null, + linkDescription: String? = null, + imageAlt: String? = null, videoUrl: String? = null, audioUrl: String? = null, fileUrl: String? = null, diff --git a/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt b/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt index 26cda3b..6959eef 100644 --- a/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt +++ b/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt @@ -16,3 +16,10 @@ object UrlNormalizer { return normalized } } + +/** + * Strips the URL scheme (http/https) and trailing slash for display purposes. + * e.g., "https://example.com/" -> "example.com" + */ +fun String.toDisplayUrl(): String = + removePrefix("https://").removePrefix("http://").removeSuffix("/") diff --git a/app/src/main/java/com/swoosh/microblog/ui/components/FileTypeColor.kt b/app/src/main/java/com/swoosh/microblog/ui/components/FileTypeColor.kt new file mode 100644 index 0000000..717b9ea --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/components/FileTypeColor.kt @@ -0,0 +1,46 @@ +package com.swoosh.microblog.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Returns an appropriate icon tint color for a file based on MIME type or file name. + * Accepts either a MIME type string, a file name, or both (MIME type takes priority). + */ +@Composable +fun fileTypeColor(mimeType: String? = null, fileName: String? = null): Color { + // Try MIME type first + if (mimeType != null) { + val color = colorForMimeType(mimeType) + if (color != null) return color + } + // Fall back to file extension + if (fileName != null) { + val color = colorForFileName(fileName) + if (color != null) return color + } + return MaterialTheme.colorScheme.onSurfaceVariant +} + +private fun colorForMimeType(mimeType: String): Color? = when { + mimeType.contains("pdf") -> Color(0xFFD32F2F) + mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0) + mimeType.contains("text") -> Color(0xFF757575) + mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32) + mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100) + else -> null +} + +private fun colorForFileName(fileName: String): Color? { + val lower = fileName.lowercase() + return when { + lower.endsWith(".pdf") -> Color(0xFFD32F2F) + lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0) + lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575) + lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32) + lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100) + lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A) + else -> null + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt b/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt index 338a130..109c0bd 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt @@ -24,7 +24,8 @@ import androidx.media3.ui.PlayerView /** * Video player composable using ExoPlayer. - * Shows a play button overlay; tap to play/pause. No autoplay. + * Shows a play button overlay; tap to play/pause. + * Media is only prepared (buffered) on first play tap to avoid unnecessary network usage. */ @Composable fun VideoPlayer( @@ -35,11 +36,11 @@ fun VideoPlayer( val context = LocalContext.current var isPlaying by remember { mutableStateOf(false) } var showOverlay by remember { mutableStateOf(true) } + var hasPrepared by remember { mutableStateOf(false) } val exoPlayer = remember(url) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) - prepare() playWhenReady = false } } @@ -72,6 +73,10 @@ fun VideoPlayer( exoPlayer.pause() showOverlay = true } else { + if (!hasPrepared) { + exoPlayer.prepare() + hasPrepared = true + } exoPlayer.play() showOverlay = false } @@ -114,6 +119,7 @@ fun VideoPlayer( /** * Compact audio player with play/pause button, progress slider, and duration text. + * Media is only prepared (buffered) on first play tap to avoid unnecessary network usage. */ @Composable fun AudioPlayer( @@ -124,11 +130,11 @@ fun AudioPlayer( var isPlaying by remember { mutableStateOf(false) } var currentPosition by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) } + var hasPrepared by remember { mutableStateOf(false) } val exoPlayer = remember(url) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) - prepare() playWhenReady = false } } @@ -190,6 +196,10 @@ fun AudioPlayer( if (isPlaying) { exoPlayer.pause() } else { + if (!hasPrepared) { + exoPlayer.prepare() + hasPrepared = true + } exoPlayer.play() } }, diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 04d437a..269c17b 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -1429,12 +1429,7 @@ fun MediaPreviewCard( context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor -> val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) if (cursor.moveToFirst() && sizeIndex >= 0) { - val bytes = cursor.getLong(sizeIndex) - when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "${bytes / 1024} KB" - else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) - } + formatFileSize(cursor.getLong(sizeIndex)) } else null } } catch (_: Exception) { @@ -1610,21 +1605,6 @@ fun TagsSection( } } -/** - * Returns an appropriate icon tint color based on file MIME type. - */ -@Composable -private fun fileTypeColor(mimeType: String?): Color { - return when { - mimeType == null -> MaterialTheme.colorScheme.onSurfaceVariant - mimeType.contains("pdf") -> Color(0xFFD32F2F) // red for PDF - mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0) // blue for DOC - mimeType.contains("text") -> Color(0xFF757575) // gray for TXT - mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32) // green for spreadsheets - mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100) // orange for presentations - else -> MaterialTheme.colorScheme.onSurfaceVariant - } -} /** * Formats file size in human-readable units. @@ -1647,7 +1627,7 @@ fun FileAttachmentComposerCard( fileMimeType: String?, onRemove: () -> Unit ) { - val iconTint = fileTypeColor(fileMimeType) + val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(mimeType = fileMimeType) OutlinedCard(modifier = Modifier.fillMaxWidth()) { Row( diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 82cb01c..7d69cb7 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -82,6 +82,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.swoosh.microblog.data.CredentialsManager +import com.swoosh.microblog.data.toDisplayUrl import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.model.FeedPost @@ -245,10 +246,7 @@ fun FeedScreen( ) if (activeAccount != null) { Text( - text = activeAccount!!.blogUrl - .removePrefix("https://") - .removePrefix("http://") - .removeSuffix("/"), + text = activeAccount!!.blogUrl.toDisplayUrl(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -264,7 +262,7 @@ fun FeedScreen( } } - if (accounts.size > 1 || accounts.isNotEmpty()) { + if (accounts.size > 1) { Icon( Icons.Default.KeyboardArrowDown, contentDescription = "Switch account", @@ -1178,10 +1176,7 @@ fun AccountListItem( }, supportingContent = { Text( - account.blogUrl - .removePrefix("https://") - .removePrefix("http://") - .removeSuffix("/"), + account.blogUrl.toDisplayUrl(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -1443,11 +1438,6 @@ fun PostCardContent( var expanded by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() - val displayText = if (expanded || post.textContent.length <= 280) { - post.textContent - } else { - post.textContent.take(280) + "..." - } val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank() @@ -1506,7 +1496,8 @@ fun PostCardContent( // Content -- the star of the show if (highlightQuery != null && highlightQuery.isNotBlank()) { HighlightedText( - text = displayText, + text = if (expanded || post.textContent.length <= 280) post.textContent + else post.textContent.take(280) + "...", query = highlightQuery, maxLines = if (expanded) Int.MAX_VALUE else 8 ) @@ -2259,22 +2250,6 @@ fun StatusBadge(post: FeedPost) { ) } -/** - * Returns an appropriate icon tint color based on file extension. - */ -@Composable -private fun fileTypeColorFromName(fileName: String): Color { - val lower = fileName.lowercase() - return when { - lower.endsWith(".pdf") -> Color(0xFFD32F2F) // red - lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0) // blue - lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575) // gray - lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32) // green - lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100) // orange - lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A) // purple - else -> MaterialTheme.colorScheme.onSurfaceVariant - } -} /** * Card displaying a file attachment in Feed/Detail screens. @@ -2286,7 +2261,7 @@ fun FileAttachmentCard( fileName: String ) { val context = LocalContext.current - val iconTint = fileTypeColorFromName(fileName) + val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(fileName = fileName) OutlinedCard( onClick = { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index f140ae6..0fabab6 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -37,6 +37,7 @@ data class SnackbarEvent( class FeedViewModel(application: Application) : AndroidViewModel(application) { + private val gson = Gson() private val accountManager = AccountManager(application) private var repository = PostRepository(application) private var tagRepository = TagRepository(application) @@ -523,20 +524,62 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(posts = sorted) } } + /** + * Parsed mobiledoc card data. Single-parse result for all card types. + */ + private data class MobiledocCards( + val imageUrls: List = emptyList(), + val videoUrl: String? = null, + val audioUrl: String? = null, + val fileUrl: String? = null, + val fileName: String? = null + ) + + /** + * Parses mobiledoc JSON once and extracts all card data (images, video, audio, file). + */ + private fun parseMobiledocCards(mobiledoc: String?): MobiledocCards { + if (mobiledoc == null) return MobiledocCards() + return try { + val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject + val cards = json.getAsJsonArray("cards") ?: return MobiledocCards() + val imageUrls = mutableListOf() + var videoUrl: String? = null + var audioUrl: String? = null + var fileUrl: String? = null + var fileName: String? = null + for (card in cards) { + val cardArray = card.asJsonArray + if (cardArray.size() < 2) continue + val cardData = cardArray[1].asJsonObject + when (cardArray[0].asString) { + "image" -> cardData.get("src")?.asString?.let { imageUrls.add(it) } + "video" -> if (videoUrl == null) videoUrl = cardData.get("src")?.asString + "audio" -> if (audioUrl == null) audioUrl = cardData.get("src")?.asString + "file" -> if (fileUrl == null) { + fileUrl = cardData.get("src")?.asString + fileName = cardData.get("fileName")?.asString ?: "file" + } + } + } + MobiledocCards(imageUrls, videoUrl, audioUrl, fileUrl, fileName) + } catch (e: Exception) { + MobiledocCards() + } + } + private fun GhostPost.toFeedPost(): FeedPost { - val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc) + val mobiledocCards = parseMobiledocCards(mobiledoc) // Use feature_image as primary, then add mobiledoc images (avoiding duplicates) val allImages = mutableListOf() if (feature_image != null) { allImages.add(feature_image) } - for (url in imageUrls) { + for (url in mobiledocCards.imageUrls) { if (url !in allImages) { allImages.add(url) } } - val fileData = extractFileCardFromMobiledoc(mobiledoc) - val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc) val isEmailOnly = status == "sent" || email_only == true return FeedPost( ghostId = id, @@ -548,8 +591,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { imageUrl = allImages.firstOrNull(), imageAlt = feature_image_alt, imageUrls = allImages, - videoUrl = videoUrl, - audioUrl = audioUrl, + videoUrl = mobiledocCards.videoUrl, + audioUrl = mobiledocCards.audioUrl, linkUrl = null, linkTitle = null, linkDescription = null, @@ -561,97 +604,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { createdAt = created_at, updatedAt = updated_at, isLocal = false, - fileUrl = fileData?.first, - fileName = fileData?.second, + fileUrl = mobiledocCards.fileUrl, + fileName = mobiledocCards.fileName, emailOnly = isEmailOnly ) } - /** - * Extracts image URLs from Ghost mobiledoc JSON. - * Image cards have the format: ["image", {"src": "url"}] - */ - private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List { - if (mobiledoc == null) return emptyList() - return try { - val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject - val cards = json.getAsJsonArray("cards") ?: return emptyList() - cards.mapNotNull { card -> - val cardArray = card.asJsonArray - if (cardArray.size() >= 2 && cardArray[0].asString == "image") { - val cardData = cardArray[1].asJsonObject - cardData.get("src")?.asString - } else null - } - } catch (e: Exception) { - emptyList() - } - } - - /** - * Extracts file card data from Ghost mobiledoc JSON. - * File cards have the format: ["file", {"src": "url", "fileName": "name"}] - * Returns a Pair of (fileUrl, fileName) or null if no file card. - */ - private fun extractFileCardFromMobiledoc(mobiledoc: String?): Pair? { - if (mobiledoc == null) return null - return try { - val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject - val cards = json.getAsJsonArray("cards") ?: return null - for (card in cards) { - val cardArray = card.asJsonArray - if (cardArray.size() >= 2 && cardArray[0].asString == "file") { - val cardData = cardArray[1].asJsonObject - val src = cardData.get("src")?.asString ?: continue - val name = cardData.get("fileName")?.asString ?: "file" - return Pair(src, name) - } - } - null - } catch (e: Exception) { - null - } - } - - /** - * Extracts video and audio URLs from Ghost mobiledoc JSON. - * Video cards: ["video", {"src": "url"}] - * Audio cards: ["audio", {"src": "url"}] - * Returns a Pair of (videoUrl, audioUrl), either may be null. - */ - private fun extractMediaUrlsFromMobiledoc(mobiledoc: String?): Pair { - if (mobiledoc == null) return null to null - return try { - val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject - val cards = json.getAsJsonArray("cards") ?: return null to null - var videoUrl: String? = null - var audioUrl: String? = null - for (card in cards) { - val cardArray = card.asJsonArray - if (cardArray.size() >= 2) { - when (cardArray[0].asString) { - "video" -> { - if (videoUrl == null) { - videoUrl = cardArray[1].asJsonObject.get("src")?.asString - } - } - "audio" -> { - if (audioUrl == null) { - audioUrl = cardArray[1].asJsonObject.get("src")?.asString - } - } - } - } - } - videoUrl to audioUrl - } catch (e: Exception) { - null to null - } - } - private fun LocalPost.toFeedPost(): FeedPost { val tagNames: List = try { - Gson().fromJson(tags, object : TypeToken>() {}.type) ?: emptyList() + gson.fromJson(tags, object : TypeToken>() {}.type) ?: emptyList() } catch (e: Exception) { emptyList() } diff --git a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt index 6304b1d..1c53f9e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt @@ -257,7 +257,7 @@ private fun PageEditorScreen( if (isNew) { onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus) } else { - val mobiledoc = MobiledocBuilder.build(content, null, null, null) + val mobiledoc = MobiledocBuilder.build(content) val updatedPage = GhostPage( title = title, mobiledoc = mobiledoc, diff --git a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt index e3de283..4e0e98a 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt @@ -40,7 +40,7 @@ class PagesViewModel(application: Application) : AndroidViewModel(application) { fun savePage(title: String, content: String, slug: String?, status: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - val mobiledoc = MobiledocBuilder.build(content, null, null, null) + val mobiledoc = MobiledocBuilder.build(content) val page = GhostPage( title = title, mobiledoc = mobiledoc, diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index b5c1f1a..c33bb40 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.swoosh.microblog.data.AccountManager +import com.swoosh.microblog.data.toDisplayUrl import com.swoosh.microblog.data.NewsletterPreferences import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.api.ApiClient @@ -166,10 +167,7 @@ fun SettingsScreen( val siteUrl = siteData.url if (!siteUrl.isNullOrBlank()) { Text( - text = siteUrl - .removePrefix("https://") - .removePrefix("http://") - .removeSuffix("/"), + text = siteUrl.toDisplayUrl(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -330,10 +328,7 @@ fun SettingsScreen( style = MaterialTheme.typography.bodyLarge ) Text( - text = activeAccount.blogUrl - .removePrefix("https://") - .removePrefix("http://") - .removeSuffix("/"), + text = activeAccount.blogUrl.toDisplayUrl(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt index 62154a5..f491afc 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt @@ -10,6 +10,7 @@ import com.swoosh.microblog.data.repository.MemberRepository import com.swoosh.microblog.data.repository.MemberStats import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.TagRepository +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,72 +35,76 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(isLoading = true) } try { - // Get local posts - val localPosts = repository.getAllLocalPostsList() - - // Get remote posts - val remotePosts = mutableListOf() - var page = 1 - var hasMore = true - while (hasMore) { - val result = repository.fetchPosts(page = page, limit = 50) - result.fold( - onSuccess = { response -> - remotePosts.addAll(response.posts.map { ghost -> - FeedPost( - ghostId = ghost.id, - title = ghost.title ?: "", - textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "", - htmlContent = ghost.html, - imageUrl = ghost.feature_image, - linkUrl = null, - linkTitle = null, - linkDescription = null, - linkImageUrl = null, - tags = ghost.tags?.map { it.name } ?: emptyList(), - status = ghost.status ?: "draft", - publishedAt = ghost.published_at, - createdAt = ghost.created_at, - updatedAt = ghost.updated_at, - isLocal = false - ) - }) - hasMore = response.meta?.pagination?.next != null - page++ - }, - onFailure = { - hasMore = false - } - ) - // Safety limit - if (page > 20) break + // Launch posts, members, and tags fetches in parallel + val postsDeferred = async { + val localPosts = repository.getAllLocalPostsList() + val remotePosts = mutableListOf() + var page = 1 + var hasMore = true + while (hasMore) { + val result = repository.fetchPosts(page = page, limit = 50) + result.fold( + onSuccess = { response -> + remotePosts.addAll(response.posts.map { ghost -> + FeedPost( + ghostId = ghost.id, + title = ghost.title ?: "", + textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "", + htmlContent = ghost.html, + imageUrl = ghost.feature_image, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + tags = ghost.tags?.map { it.name } ?: emptyList(), + status = ghost.status ?: "draft", + publishedAt = ghost.published_at, + createdAt = ghost.created_at, + updatedAt = ghost.updated_at, + isLocal = false + ) + }) + hasMore = response.meta?.pagination?.next != null + page++ + }, + onFailure = { + hasMore = false + } + ) + if (page > 20) break + } + val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet() + val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds } + Pair(localPosts, uniqueRemotePosts) } - // Remove remote duplicates that exist locally - val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet() - val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds } + val membersDeferred = async { + try { + val membersResult = memberRepository.fetchAllMembers() + membersResult.getOrNull()?.let { members -> + memberRepository.getMemberStats(members) + } + } catch (e: Exception) { + null + } + } + + val tagsDeferred = async { + try { + tagRepository.fetchTags().getOrNull() + ?.sortedByDescending { it.count?.posts ?: 0 } + ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + val (localPosts, uniqueRemotePosts) = postsDeferred.await() + val memberStats = membersDeferred.await() + val tagStats = tagsDeferred.await() val stats = OverallStats.calculate(localPosts, uniqueRemotePosts) - // Fetch member stats (non-fatal if it fails) - val memberStats = try { - val membersResult = memberRepository.fetchAllMembers() - membersResult.getOrNull()?.let { members -> - memberRepository.getMemberStats(members) - } - } catch (e: Exception) { - null - } - - // Fetch tag stats - val tagStats = try { - tagRepository.fetchTags().getOrNull() - ?.sortedByDescending { it.count?.posts ?: 0 } - ?: emptyList() - } catch (e: Exception) { - emptyList() - } - // Count posts without any tags val totalPosts = localPosts.size + uniqueRemotePosts.size val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() } diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 45b4078..e604723 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -18,6 +18,8 @@ class PostUploadWorker( workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { + private val gson = Gson() + override suspend fun doWork(): Result { val repository = PostRepository(applicationContext) val queuedPosts = repository.getQueuedPosts() @@ -118,7 +120,7 @@ class PostUploadWorker( // Parse tags from JSON stored in LocalPost val tagNames: List = try { - Gson().fromJson(post.tags, object : TypeToken>() {}.type) ?: emptyList() + gson.fromJson(post.tags, object : TypeToken>() {}.type) ?: emptyList() } catch (e: Exception) { emptyList() } diff --git a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt index b916527..0f7a7f0 100644 --- a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -199,7 +199,7 @@ class MobiledocBuilderTest { @Test fun `build with separate params and no link produces same as null preview`() { val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?) - val resultB = MobiledocBuilder.build("Hello", null, null, null) + val resultB = MobiledocBuilder.build("Hello") assertEquals(resultA, resultB) } @@ -207,9 +207,9 @@ class MobiledocBuilderTest { fun `build with separate params includes link data`() { val result = MobiledocBuilder.build( "Text", - "https://test.com", - "Test Title", - "Test Desc" + linkUrl = "https://test.com", + linkTitle = "Test Title", + linkDescription = "Test Desc" ) assertTrue(result.contains("https://test.com")) assertTrue(result.contains("Test Title")) @@ -219,7 +219,7 @@ class MobiledocBuilderTest { @Test fun `build with separate params handles null title and description`() { - val result = MobiledocBuilder.build("Text", "https://test.com", null, null) + val result = MobiledocBuilder.build("Text", linkUrl = "https://test.com") val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) assertTrue(result.contains("bookmark")) @@ -257,8 +257,9 @@ class MobiledocBuilderTest { @Test fun `build with image card produces valid JSON`() { val result = MobiledocBuilder.build( - "Post text", null, null, null, - "https://example.com/photo.jpg", "A sunset" + "Post text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "A sunset" ) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) @@ -267,8 +268,9 @@ class MobiledocBuilderTest { @Test fun `build with image card includes image type`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "Alt text" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "Alt text" ) assertTrue("Should contain image card type", result.contains("\"image\"")) } @@ -276,8 +278,9 @@ class MobiledocBuilderTest { @Test fun `build with image card includes src`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "Alt text" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "Alt text" ) assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg")) } @@ -285,8 +288,9 @@ class MobiledocBuilderTest { @Test fun `build with image card includes alt text`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "A beautiful sunset" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "A beautiful sunset" ) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") @@ -300,8 +304,8 @@ class MobiledocBuilderTest { @Test fun `build with image card and null alt uses empty string`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", null + "Text", + imageUrls = listOf("https://example.com/photo.jpg") ) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") @@ -313,8 +317,9 @@ class MobiledocBuilderTest { @Test fun `build with image card includes caption field`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "Alt" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "Alt" ) val json = JsonParser.parseString(result).asJsonObject val card = json.getAsJsonArray("cards").get(0).asJsonArray @@ -325,8 +330,9 @@ class MobiledocBuilderTest { @Test fun `build with image card has card section`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "Alt" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "Alt" ) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -336,8 +342,12 @@ class MobiledocBuilderTest { @Test fun `build with image and link has both cards`() { val result = MobiledocBuilder.build( - "Text", "https://link.com", "Link Title", "Link Desc", - "https://example.com/photo.jpg", "Image alt" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + linkUrl = "https://link.com", + linkTitle = "Link Title", + linkDescription = "Link Desc", + imageAlt = "Image alt" ) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") @@ -350,8 +360,12 @@ class MobiledocBuilderTest { @Test fun `build with image and link has three sections`() { val result = MobiledocBuilder.build( - "Text", "https://link.com", "Title", "Desc", - "https://example.com/photo.jpg", "Alt" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + linkUrl = "https://link.com", + linkTitle = "Title", + linkDescription = "Desc", + imageAlt = "Alt" ) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -361,8 +375,9 @@ class MobiledocBuilderTest { @Test fun `build with image card escapes alt text`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "He said \"hello\"" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "He said \"hello\"" ) val json = JsonParser.parseString(result).asJsonObject assertNotNull("Should produce valid JSON with escaped alt text", json) @@ -370,10 +385,7 @@ class MobiledocBuilderTest { @Test fun `build without image produces no image card`() { - val result = MobiledocBuilder.build( - "Text", null, null, null, - null, null - ) + val result = MobiledocBuilder.build("Text") val json = JsonParser.parseString(result).asJsonObject assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty) } @@ -381,8 +393,9 @@ class MobiledocBuilderTest { @Test fun `build with image card section references correct card index`() { val result = MobiledocBuilder.build( - "Text", null, null, null, - "https://example.com/photo.jpg", "Alt" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + imageAlt = "Alt" ) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -394,8 +407,11 @@ class MobiledocBuilderTest { @Test fun `build with image and link card sections reference correct indices`() { val result = MobiledocBuilder.build( - "Text", "https://link.com", "Title", null, - "https://example.com/photo.jpg", "Alt" + "Text", + imageUrls = listOf("https://example.com/photo.jpg"), + linkUrl = "https://link.com", + linkTitle = "Title", + imageAlt = "Alt" ) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -414,7 +430,7 @@ class MobiledocBuilderTest { @Test fun `build with single image produces valid JSON`() { val result = MobiledocBuilder.build( - "Hello", listOf("https://example.com/img.jpg"), null, null, null + "Hello", listOf("https://example.com/img.jpg") ) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) @@ -423,7 +439,7 @@ class MobiledocBuilderTest { @Test fun `build with single image has one image card`() { val result = MobiledocBuilder.build( - "Hello", listOf("https://example.com/img.jpg"), null, null, null + "Hello", listOf("https://example.com/img.jpg") ) val json = JsonParser.parseString(result).asJsonObject assertEquals(1, json.getAsJsonArray("cards").size()) @@ -436,7 +452,7 @@ class MobiledocBuilderTest { @Test fun `build with single image has two sections`() { val result = MobiledocBuilder.build( - "Hello", listOf("https://example.com/img.jpg"), null, null, null + "Hello", listOf("https://example.com/img.jpg") ) val json = JsonParser.parseString(result).asJsonObject assertEquals(2, json.getAsJsonArray("sections").size()) @@ -449,7 +465,7 @@ class MobiledocBuilderTest { "https://example.com/img2.jpg", "https://example.com/img3.jpg" ) - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) } @@ -461,7 +477,7 @@ class MobiledocBuilderTest { "https://example.com/img2.jpg", "https://example.com/img3.jpg" ) - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject assertEquals(3, json.getAsJsonArray("cards").size()) } @@ -472,7 +488,7 @@ class MobiledocBuilderTest { "https://example.com/img1.jpg", "https://example.com/img2.jpg" ) - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject // 1 text section + 2 card sections assertEquals(3, json.getAsJsonArray("sections").size()) @@ -484,7 +500,7 @@ class MobiledocBuilderTest { "https://example.com/img1.jpg", "https://example.com/img2.jpg" ) - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") for (i in 0 until cards.size()) { @@ -500,7 +516,7 @@ class MobiledocBuilderTest { "https://example.com/img2.jpg", "https://example.com/img3.jpg" ) - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString) @@ -512,7 +528,7 @@ class MobiledocBuilderTest { fun `build with images and link has both image and bookmark cards`() { val images = listOf("https://example.com/img1.jpg") val result = MobiledocBuilder.build( - "Hello", images, "https://example.com", "Title", "Desc" + "Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc" ) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") @@ -528,7 +544,7 @@ class MobiledocBuilderTest { fun `build with images and link has correct number of sections`() { val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") val result = MobiledocBuilder.build( - "Hello", images, "https://example.com", "Title", "Desc" + "Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc" ) val json = JsonParser.parseString(result).asJsonObject // 1 text section + 2 image card sections + 1 bookmark card section @@ -538,7 +554,7 @@ class MobiledocBuilderTest { @Test fun `build with images card sections reference correct card indices`() { val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -556,7 +572,7 @@ class MobiledocBuilderTest { @Test fun `build with empty image list produces no image cards`() { - val result = MobiledocBuilder.build("Hello", emptyList(), null, null, null) + val result = MobiledocBuilder.build("Hello", emptyList()) val json = JsonParser.parseString(result).asJsonObject assertTrue(json.getAsJsonArray("cards").isEmpty) assertEquals(1, json.getAsJsonArray("sections").size()) @@ -565,14 +581,14 @@ class MobiledocBuilderTest { @Test fun `build with empty image list matches no-image build`() { val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?) - val resultB = MobiledocBuilder.build("Hello", emptyList(), null, null, null) + val resultB = MobiledocBuilder.build("Hello", emptyList()) assertEquals(resultA, resultB) } @Test fun `build with image URL containing special chars produces valid JSON`() { val images = listOf("https://example.com/img?id=1&name=\"test\"") - val result = MobiledocBuilder.build("Hello", images, null, null, null) + val result = MobiledocBuilder.build("Hello", images) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) } @@ -585,7 +601,7 @@ class MobiledocBuilderTest { "https://example.com/img1.jpg", "https://example.com/img2.jpg" ) - val result = MobiledocBuilder.build("Text", images, null, null, null, "First image alt") + val result = MobiledocBuilder.build("Text", images, imageAlt = "First image alt") val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") @@ -603,8 +619,8 @@ class MobiledocBuilderTest { @Test fun `build with video only produces valid JSON with video card`() { val result = MobiledocBuilder.build( - "Check this video", emptyList(), null, null, null, null, - "https://example.com/video.mp4", null + "Check this video", + videoUrl = "https://example.com/video.mp4" ) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) @@ -621,8 +637,8 @@ class MobiledocBuilderTest { @Test fun `build with video has correct sections`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, - "https://example.com/video.mp4", null + "Text", + videoUrl = "https://example.com/video.mp4" ) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -638,8 +654,8 @@ class MobiledocBuilderTest { @Test fun `build with audio only produces valid JSON with audio card`() { val result = MobiledocBuilder.build( - "Listen to this", emptyList(), null, null, null, null, - null, "https://example.com/audio.mp3" + "Listen to this", + audioUrl = "https://example.com/audio.mp3" ) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) @@ -655,8 +671,8 @@ class MobiledocBuilderTest { @Test fun `build with audio has correct sections`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, - null, "https://example.com/audio.mp3" + "Text", + audioUrl = "https://example.com/audio.mp3" ) val json = JsonParser.parseString(result).asJsonObject val sections = json.getAsJsonArray("sections") @@ -670,10 +686,12 @@ class MobiledocBuilderTest { val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") val result = MobiledocBuilder.build( "Full post", images, - "https://link.com", "Link Title", "Link Desc", - "Alt text", - "https://example.com/video.mp4", - "https://example.com/audio.mp3" + linkUrl = "https://link.com", + linkTitle = "Link Title", + linkDescription = "Link Desc", + imageAlt = "Alt text", + videoUrl = "https://example.com/video.mp4", + audioUrl = "https://example.com/audio.mp3" ) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) @@ -704,9 +722,9 @@ class MobiledocBuilderTest { @Test fun `build with video and audio but no images produces correct cards`() { val result = MobiledocBuilder.build( - "Media post", emptyList(), null, null, null, null, - "https://example.com/video.mp4", - "https://example.com/audio.mp3" + "Media post", + videoUrl = "https://example.com/video.mp4", + audioUrl = "https://example.com/audio.mp3" ) val json = JsonParser.parseString(result).asJsonObject val cards = json.getAsJsonArray("cards") @@ -718,8 +736,8 @@ class MobiledocBuilderTest { @Test fun `build with video URL containing special chars produces valid JSON`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, - "https://example.com/video?id=1&name=\"test\"", null + "Text", + videoUrl = "https://example.com/video?id=1&name=\"test\"" ) val json = JsonParser.parseString(result).asJsonObject assertNotNull(json) @@ -730,7 +748,7 @@ class MobiledocBuilderTest { @Test fun `build with file card produces valid JSON`() { val result = MobiledocBuilder.build( - "Post text", emptyList(), null, null, null, null, + "Post text", fileUrl = "https://example.com/files/report.pdf", fileName = "report.pdf", fileSize = 102400 @@ -742,7 +760,7 @@ class MobiledocBuilderTest { @Test fun `build with file card includes file type`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, + "Text", fileUrl = "https://example.com/files/doc.pdf", fileName = "doc.pdf", fileSize = 5000 @@ -757,7 +775,7 @@ class MobiledocBuilderTest { @Test fun `build with file card includes src fileName and fileSize`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, + "Text", fileUrl = "https://example.com/files/report.pdf", fileName = "report.pdf", fileSize = 204800 @@ -773,7 +791,7 @@ class MobiledocBuilderTest { @Test fun `build with file card has correct section count`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, + "Text", fileUrl = "https://example.com/file.pdf", fileName = "file.pdf", fileSize = 1000 @@ -787,7 +805,11 @@ class MobiledocBuilderTest { fun `build with file card comes after image and bookmark cards`() { val images = listOf("https://example.com/img.jpg") val result = MobiledocBuilder.build( - "Text", images, "https://link.com", "Link Title", "Desc", "Alt", + "Text", images, + linkUrl = "https://link.com", + linkTitle = "Link Title", + linkDescription = "Desc", + imageAlt = "Alt", fileUrl = "https://example.com/file.pdf", fileName = "file.pdf", fileSize = 1024 @@ -805,7 +827,10 @@ class MobiledocBuilderTest { fun `build with file card and all attachments has correct section count`() { val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") val result = MobiledocBuilder.build( - "Text", images, "https://link.com", "Title", "Desc", null, + "Text", images, + linkUrl = "https://link.com", + linkTitle = "Title", + linkDescription = "Desc", fileUrl = "https://example.com/file.pdf", fileName = "file.pdf", fileSize = 500 @@ -817,10 +842,7 @@ class MobiledocBuilderTest { @Test fun `build without file produces no file card`() { - val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, - fileUrl = null, fileName = null, fileSize = 0 - ) + val result = MobiledocBuilder.build("Text") val json = JsonParser.parseString(result).asJsonObject assertTrue(json.getAsJsonArray("cards").isEmpty) } @@ -828,7 +850,7 @@ class MobiledocBuilderTest { @Test fun `build with file card escapes fileName`() { val result = MobiledocBuilder.build( - "Text", emptyList(), null, null, null, null, + "Text", fileUrl = "https://example.com/file.pdf", fileName = "my \"special\" file.pdf", fileSize = 100