refactor: simplify code after /simplify review

- Parallelize StatsViewModel fetches (posts, members, tags via async/await)
- Collapse MobiledocBuilder overloads into single function with defaults
- Extract shared FileTypeColor composable from duplicated color mappings
- Remove redundant state in FeedViewModel and FeedScreen
- Unify formatFileSize usage, remove inline duplication
- Fix minor issues in MediaPlayers, PostUploadWorker, Pages, Settings
This commit is contained in:
Paweł Orzech 2026-03-20 09:05:22 +01:00
parent 29927a7638
commit da8a90470d
No known key found for this signature in database
13 changed files with 305 additions and 376 deletions

View file

@ -9,81 +9,7 @@ import com.swoosh.microblog.data.model.LinkPreview
object MobiledocBuilder { object MobiledocBuilder {
fun build(text: String, linkPreview: LinkPreview?): String { fun build(text: String, linkPreview: LinkPreview?): String {
return build(text, linkPreview?.url, linkPreview?.title, linkPreview?.description) return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = 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<String>,
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<String>,
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<String>,
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
)
} }
/** /**
@ -94,11 +20,11 @@ object MobiledocBuilder {
*/ */
fun build( fun build(
text: String, text: String,
imageUrls: List<String>, imageUrls: List<String> = emptyList(),
linkUrl: String?, linkUrl: String? = null,
linkTitle: String?, linkTitle: String? = null,
linkDescription: String?, linkDescription: String? = null,
imageAlt: String?, imageAlt: String? = null,
videoUrl: String? = null, videoUrl: String? = null,
audioUrl: String? = null, audioUrl: String? = null,
fileUrl: String? = null, fileUrl: String? = null,

View file

@ -16,3 +16,10 @@ object UrlNormalizer {
return normalized 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("/")

View file

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

View file

@ -24,7 +24,8 @@ import androidx.media3.ui.PlayerView
/** /**
* Video player composable using ExoPlayer. * 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 @Composable
fun VideoPlayer( fun VideoPlayer(
@ -35,11 +36,11 @@ fun VideoPlayer(
val context = LocalContext.current val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) } var isPlaying by remember { mutableStateOf(false) }
var showOverlay by remember { mutableStateOf(true) } var showOverlay by remember { mutableStateOf(true) }
var hasPrepared by remember { mutableStateOf(false) }
val exoPlayer = remember(url) { val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url)) setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = false playWhenReady = false
} }
} }
@ -72,6 +73,10 @@ fun VideoPlayer(
exoPlayer.pause() exoPlayer.pause()
showOverlay = true showOverlay = true
} else { } else {
if (!hasPrepared) {
exoPlayer.prepare()
hasPrepared = true
}
exoPlayer.play() exoPlayer.play()
showOverlay = false showOverlay = false
} }
@ -114,6 +119,7 @@ fun VideoPlayer(
/** /**
* Compact audio player with play/pause button, progress slider, and duration text. * 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 @Composable
fun AudioPlayer( fun AudioPlayer(
@ -124,11 +130,11 @@ fun AudioPlayer(
var isPlaying by remember { mutableStateOf(false) } var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) } var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) }
var hasPrepared by remember { mutableStateOf(false) }
val exoPlayer = remember(url) { val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url)) setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = false playWhenReady = false
} }
} }
@ -190,6 +196,10 @@ fun AudioPlayer(
if (isPlaying) { if (isPlaying) {
exoPlayer.pause() exoPlayer.pause()
} else { } else {
if (!hasPrepared) {
exoPlayer.prepare()
hasPrepared = true
}
exoPlayer.play() exoPlayer.play()
} }
}, },

View file

@ -1429,12 +1429,7 @@ fun MediaPreviewCard(
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor -> context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (cursor.moveToFirst() && sizeIndex >= 0) { if (cursor.moveToFirst() && sizeIndex >= 0) {
val bytes = cursor.getLong(sizeIndex) formatFileSize(cursor.getLong(sizeIndex))
when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
} else null } else null
} }
} catch (_: Exception) { } 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. * Formats file size in human-readable units.
@ -1647,7 +1627,7 @@ fun FileAttachmentComposerCard(
fileMimeType: String?, fileMimeType: String?,
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
val iconTint = fileTypeColor(fileMimeType) val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(mimeType = fileMimeType)
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row( Row(

View file

@ -82,6 +82,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.toDisplayUrl
import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
@ -245,10 +246,7 @@ fun FeedScreen(
) )
if (activeAccount != null) { if (activeAccount != null) {
Text( Text(
text = activeAccount!!.blogUrl text = activeAccount!!.blogUrl.toDisplayUrl(),
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@ -264,7 +262,7 @@ fun FeedScreen(
} }
} }
if (accounts.size > 1 || accounts.isNotEmpty()) { if (accounts.size > 1) {
Icon( Icon(
Icons.Default.KeyboardArrowDown, Icons.Default.KeyboardArrowDown,
contentDescription = "Switch account", contentDescription = "Switch account",
@ -1178,10 +1176,7 @@ fun AccountListItem(
}, },
supportingContent = { supportingContent = {
Text( Text(
account.blogUrl account.blogUrl.toDisplayUrl(),
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -1443,11 +1438,6 @@ fun PostCardContent(
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showContextMenu by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() 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 isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank() val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
@ -1506,7 +1496,8 @@ fun PostCardContent(
// Content -- the star of the show // Content -- the star of the show
if (highlightQuery != null && highlightQuery.isNotBlank()) { if (highlightQuery != null && highlightQuery.isNotBlank()) {
HighlightedText( HighlightedText(
text = displayText, text = if (expanded || post.textContent.length <= 280) post.textContent
else post.textContent.take(280) + "...",
query = highlightQuery, query = highlightQuery,
maxLines = if (expanded) Int.MAX_VALUE else 8 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. * Card displaying a file attachment in Feed/Detail screens.
@ -2286,7 +2261,7 @@ fun FileAttachmentCard(
fileName: String fileName: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val iconTint = fileTypeColorFromName(fileName) val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(fileName = fileName)
OutlinedCard( OutlinedCard(
onClick = { onClick = {

View file

@ -37,6 +37,7 @@ data class SnackbarEvent(
class FeedViewModel(application: Application) : AndroidViewModel(application) { class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val gson = Gson()
private val accountManager = AccountManager(application) private val accountManager = AccountManager(application)
private var repository = PostRepository(application) private var repository = PostRepository(application)
private var tagRepository = TagRepository(application) private var tagRepository = TagRepository(application)
@ -523,20 +524,62 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(posts = sorted) } _uiState.update { it.copy(posts = sorted) }
} }
/**
* Parsed mobiledoc card data. Single-parse result for all card types.
*/
private data class MobiledocCards(
val imageUrls: List<String> = 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<String>()
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 { private fun GhostPost.toFeedPost(): FeedPost {
val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc) val mobiledocCards = parseMobiledocCards(mobiledoc)
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates) // Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
val allImages = mutableListOf<String>() val allImages = mutableListOf<String>()
if (feature_image != null) { if (feature_image != null) {
allImages.add(feature_image) allImages.add(feature_image)
} }
for (url in imageUrls) { for (url in mobiledocCards.imageUrls) {
if (url !in allImages) { if (url !in allImages) {
allImages.add(url) allImages.add(url)
} }
} }
val fileData = extractFileCardFromMobiledoc(mobiledoc)
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
val isEmailOnly = status == "sent" || email_only == true val isEmailOnly = status == "sent" || email_only == true
return FeedPost( return FeedPost(
ghostId = id, ghostId = id,
@ -548,8 +591,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
imageUrl = allImages.firstOrNull(), imageUrl = allImages.firstOrNull(),
imageAlt = feature_image_alt, imageAlt = feature_image_alt,
imageUrls = allImages, imageUrls = allImages,
videoUrl = videoUrl, videoUrl = mobiledocCards.videoUrl,
audioUrl = audioUrl, audioUrl = mobiledocCards.audioUrl,
linkUrl = null, linkUrl = null,
linkTitle = null, linkTitle = null,
linkDescription = null, linkDescription = null,
@ -561,97 +604,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
createdAt = created_at, createdAt = created_at,
updatedAt = updated_at, updatedAt = updated_at,
isLocal = false, isLocal = false,
fileUrl = fileData?.first, fileUrl = mobiledocCards.fileUrl,
fileName = fileData?.second, fileName = mobiledocCards.fileName,
emailOnly = isEmailOnly emailOnly = isEmailOnly
) )
} }
/**
* Extracts image URLs from Ghost mobiledoc JSON.
* Image cards have the format: ["image", {"src": "url"}]
*/
private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List<String> {
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<String, String>? {
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<String?, String?> {
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 { private fun LocalPost.toFeedPost(): FeedPost {
val tagNames: List<String> = try { val tagNames: List<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList() gson.fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View file

@ -257,7 +257,7 @@ private fun PageEditorScreen(
if (isNew) { if (isNew) {
onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus) onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus)
} else { } else {
val mobiledoc = MobiledocBuilder.build(content, null, null, null) val mobiledoc = MobiledocBuilder.build(content)
val updatedPage = GhostPage( val updatedPage = GhostPage(
title = title, title = title,
mobiledoc = mobiledoc, mobiledoc = mobiledoc,

View file

@ -40,7 +40,7 @@ class PagesViewModel(application: Application) : AndroidViewModel(application) {
fun savePage(title: String, content: String, slug: String?, status: String) { fun savePage(title: String, content: String, slug: String?, status: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
val mobiledoc = MobiledocBuilder.build(content, null, null, null) val mobiledoc = MobiledocBuilder.build(content)
val page = GhostPage( val page = GhostPage(
title = title, title = title,
mobiledoc = mobiledoc, mobiledoc = mobiledoc,

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.toDisplayUrl
import com.swoosh.microblog.data.NewsletterPreferences import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient import com.swoosh.microblog.data.api.ApiClient
@ -166,10 +167,7 @@ fun SettingsScreen(
val siteUrl = siteData.url val siteUrl = siteData.url
if (!siteUrl.isNullOrBlank()) { if (!siteUrl.isNullOrBlank()) {
Text( Text(
text = siteUrl text = siteUrl.toDisplayUrl(),
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -330,10 +328,7 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( Text(
text = activeAccount.blogUrl text = activeAccount.blogUrl.toDisplayUrl(),
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View file

@ -10,6 +10,7 @@ import com.swoosh.microblog.data.repository.MemberRepository
import com.swoosh.microblog.data.repository.MemberStats import com.swoosh.microblog.data.repository.MemberStats
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository import com.swoosh.microblog.data.repository.TagRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -34,72 +35,76 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
try { try {
// Get local posts // Launch posts, members, and tags fetches in parallel
val localPosts = repository.getAllLocalPostsList() val postsDeferred = async {
val localPosts = repository.getAllLocalPostsList()
// Get remote posts val remotePosts = mutableListOf<FeedPost>()
val remotePosts = mutableListOf<FeedPost>() var page = 1
var page = 1 var hasMore = true
var hasMore = true while (hasMore) {
while (hasMore) { val result = repository.fetchPosts(page = page, limit = 50)
val result = repository.fetchPosts(page = page, limit = 50) result.fold(
result.fold( onSuccess = { response ->
onSuccess = { response -> remotePosts.addAll(response.posts.map { ghost ->
remotePosts.addAll(response.posts.map { ghost -> FeedPost(
FeedPost( ghostId = ghost.id,
ghostId = ghost.id, title = ghost.title ?: "",
title = ghost.title ?: "", textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "",
textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "", htmlContent = ghost.html,
htmlContent = ghost.html, imageUrl = ghost.feature_image,
imageUrl = ghost.feature_image, linkUrl = null,
linkUrl = null, linkTitle = null,
linkTitle = null, linkDescription = null,
linkDescription = null, linkImageUrl = null,
linkImageUrl = null, tags = ghost.tags?.map { it.name } ?: emptyList(),
tags = ghost.tags?.map { it.name } ?: emptyList(), status = ghost.status ?: "draft",
status = ghost.status ?: "draft", publishedAt = ghost.published_at,
publishedAt = ghost.published_at, createdAt = ghost.created_at,
createdAt = ghost.created_at, updatedAt = ghost.updated_at,
updatedAt = ghost.updated_at, isLocal = false
isLocal = false )
) })
}) hasMore = response.meta?.pagination?.next != null
hasMore = response.meta?.pagination?.next != null page++
page++ },
}, onFailure = {
onFailure = { hasMore = false
hasMore = false }
} )
) if (page > 20) break
// Safety limit }
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 membersDeferred = async {
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet() try {
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds } 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) 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 // Count posts without any tags
val totalPosts = localPosts.size + uniqueRemotePosts.size val totalPosts = localPosts.size + uniqueRemotePosts.size
val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() } val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() }

View file

@ -18,6 +18,8 @@ class PostUploadWorker(
workerParams: WorkerParameters workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
private val gson = Gson()
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val repository = PostRepository(applicationContext) val repository = PostRepository(applicationContext)
val queuedPosts = repository.getQueuedPosts() val queuedPosts = repository.getQueuedPosts()
@ -118,7 +120,7 @@ class PostUploadWorker(
// Parse tags from JSON stored in LocalPost // Parse tags from JSON stored in LocalPost
val tagNames: List<String> = try { val tagNames: List<String> = try {
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList() gson.fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View file

@ -199,7 +199,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with separate params and no link produces same as null preview`() { fun `build with separate params and no link produces same as null preview`() {
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?) val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
val resultB = MobiledocBuilder.build("Hello", null, null, null) val resultB = MobiledocBuilder.build("Hello")
assertEquals(resultA, resultB) assertEquals(resultA, resultB)
} }
@ -207,9 +207,9 @@ class MobiledocBuilderTest {
fun `build with separate params includes link data`() { fun `build with separate params includes link data`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", "Text",
"https://test.com", linkUrl = "https://test.com",
"Test Title", linkTitle = "Test Title",
"Test Desc" linkDescription = "Test Desc"
) )
assertTrue(result.contains("https://test.com")) assertTrue(result.contains("https://test.com"))
assertTrue(result.contains("Test Title")) assertTrue(result.contains("Test Title"))
@ -219,7 +219,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with separate params handles null title and description`() { 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 val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
assertTrue(result.contains("bookmark")) assertTrue(result.contains("bookmark"))
@ -257,8 +257,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card produces valid JSON`() { fun `build with image card produces valid JSON`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Post text", null, null, null, "Post text",
"https://example.com/photo.jpg", "A sunset" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "A sunset"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -267,8 +268,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card includes image type`() { fun `build with image card includes image type`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt text" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt text"
) )
assertTrue("Should contain image card type", result.contains("\"image\"")) assertTrue("Should contain image card type", result.contains("\"image\""))
} }
@ -276,8 +278,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card includes src`() { fun `build with image card includes src`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt text" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt text"
) )
assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg")) assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg"))
} }
@ -285,8 +288,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card includes alt text`() { fun `build with image card includes alt text`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "A beautiful sunset" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "A beautiful sunset"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -300,8 +304,8 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card and null alt uses empty string`() { fun `build with image card and null alt uses empty string`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", null imageUrls = listOf("https://example.com/photo.jpg")
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -313,8 +317,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card includes caption field`() { fun `build with image card includes caption field`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val card = json.getAsJsonArray("cards").get(0).asJsonArray val card = json.getAsJsonArray("cards").get(0).asJsonArray
@ -325,8 +330,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card has card section`() { fun `build with image card has card section`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -336,8 +342,12 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image and link has both cards`() { fun `build with image and link has both cards`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", "https://link.com", "Link Title", "Link Desc", "Text",
"https://example.com/photo.jpg", "Image alt" 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 json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -350,8 +360,12 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image and link has three sections`() { fun `build with image and link has three sections`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", "https://link.com", "Title", "Desc", "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
linkUrl = "https://link.com",
linkTitle = "Title",
linkDescription = "Desc",
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -361,8 +375,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card escapes alt text`() { fun `build with image card escapes alt text`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "He said \"hello\"" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "He said \"hello\""
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull("Should produce valid JSON with escaped alt text", json) assertNotNull("Should produce valid JSON with escaped alt text", json)
@ -370,10 +385,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build without image produces no image card`() { fun `build without image produces no image card`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build("Text")
"Text", null, null, null,
null, null
)
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty) assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty)
} }
@ -381,8 +393,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image card section references correct card index`() { fun `build with image card section references correct card index`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -394,8 +407,11 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with image and link card sections reference correct indices`() { fun `build with image and link card sections reference correct indices`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", "https://link.com", "Title", null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
linkUrl = "https://link.com",
linkTitle = "Title",
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -414,7 +430,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with single image produces valid JSON`() { fun `build with single image produces valid JSON`() {
val result = MobiledocBuilder.build( 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 val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -423,7 +439,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with single image has one image card`() { fun `build with single image has one image card`() {
val result = MobiledocBuilder.build( 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 val json = JsonParser.parseString(result).asJsonObject
assertEquals(1, json.getAsJsonArray("cards").size()) assertEquals(1, json.getAsJsonArray("cards").size())
@ -436,7 +452,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with single image has two sections`() { fun `build with single image has two sections`() {
val result = MobiledocBuilder.build( 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 val json = JsonParser.parseString(result).asJsonObject
assertEquals(2, json.getAsJsonArray("sections").size()) assertEquals(2, json.getAsJsonArray("sections").size())
@ -449,7 +465,7 @@ class MobiledocBuilderTest {
"https://example.com/img2.jpg", "https://example.com/img2.jpg",
"https://example.com/img3.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 json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
} }
@ -461,7 +477,7 @@ class MobiledocBuilderTest {
"https://example.com/img2.jpg", "https://example.com/img2.jpg",
"https://example.com/img3.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 json = JsonParser.parseString(result).asJsonObject
assertEquals(3, json.getAsJsonArray("cards").size()) assertEquals(3, json.getAsJsonArray("cards").size())
} }
@ -472,7 +488,7 @@ class MobiledocBuilderTest {
"https://example.com/img1.jpg", "https://example.com/img1.jpg",
"https://example.com/img2.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 json = JsonParser.parseString(result).asJsonObject
// 1 text section + 2 card sections // 1 text section + 2 card sections
assertEquals(3, json.getAsJsonArray("sections").size()) assertEquals(3, json.getAsJsonArray("sections").size())
@ -484,7 +500,7 @@ class MobiledocBuilderTest {
"https://example.com/img1.jpg", "https://example.com/img1.jpg",
"https://example.com/img2.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 json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
for (i in 0 until cards.size()) { for (i in 0 until cards.size()) {
@ -500,7 +516,7 @@ class MobiledocBuilderTest {
"https://example.com/img2.jpg", "https://example.com/img2.jpg",
"https://example.com/img3.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 json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString) 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`() { fun `build with images and link has both image and bookmark cards`() {
val images = listOf("https://example.com/img1.jpg") val images = listOf("https://example.com/img1.jpg")
val result = MobiledocBuilder.build( 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 json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -528,7 +544,7 @@ class MobiledocBuilderTest {
fun `build with images and link has correct number of sections`() { 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 images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build( 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 json = JsonParser.parseString(result).asJsonObject
// 1 text section + 2 image card sections + 1 bookmark card section // 1 text section + 2 image card sections + 1 bookmark card section
@ -538,7 +554,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with images card sections reference correct card indices`() { fun `build with images card sections reference correct card indices`() {
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") 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 json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -556,7 +572,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with empty image list produces no image cards`() { 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 val json = JsonParser.parseString(result).asJsonObject
assertTrue(json.getAsJsonArray("cards").isEmpty) assertTrue(json.getAsJsonArray("cards").isEmpty)
assertEquals(1, json.getAsJsonArray("sections").size()) assertEquals(1, json.getAsJsonArray("sections").size())
@ -565,14 +581,14 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with empty image list matches no-image build`() { fun `build with empty image list matches no-image build`() {
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?) 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) assertEquals(resultA, resultB)
} }
@Test @Test
fun `build with image URL containing special chars produces valid JSON`() { fun `build with image URL containing special chars produces valid JSON`() {
val images = listOf("https://example.com/img?id=1&name=\"test\"") 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 val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
} }
@ -585,7 +601,7 @@ class MobiledocBuilderTest {
"https://example.com/img1.jpg", "https://example.com/img1.jpg",
"https://example.com/img2.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 json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -603,8 +619,8 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with video only produces valid JSON with video card`() { fun `build with video only produces valid JSON with video card`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Check this video", emptyList(), null, null, null, null, "Check this video",
"https://example.com/video.mp4", null videoUrl = "https://example.com/video.mp4"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -621,8 +637,8 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with video has correct sections`() { fun `build with video has correct sections`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
"https://example.com/video.mp4", null videoUrl = "https://example.com/video.mp4"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -638,8 +654,8 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with audio only produces valid JSON with audio card`() { fun `build with audio only produces valid JSON with audio card`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Listen to this", emptyList(), null, null, null, null, "Listen to this",
null, "https://example.com/audio.mp3" audioUrl = "https://example.com/audio.mp3"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -655,8 +671,8 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with audio has correct sections`() { fun `build with audio has correct sections`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
null, "https://example.com/audio.mp3" audioUrl = "https://example.com/audio.mp3"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") 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 images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Full post", images, "Full post", images,
"https://link.com", "Link Title", "Link Desc", linkUrl = "https://link.com",
"Alt text", linkTitle = "Link Title",
"https://example.com/video.mp4", linkDescription = "Link Desc",
"https://example.com/audio.mp3" imageAlt = "Alt text",
videoUrl = "https://example.com/video.mp4",
audioUrl = "https://example.com/audio.mp3"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -704,9 +722,9 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with video and audio but no images produces correct cards`() { fun `build with video and audio but no images produces correct cards`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Media post", emptyList(), null, null, null, null, "Media post",
"https://example.com/video.mp4", videoUrl = "https://example.com/video.mp4",
"https://example.com/audio.mp3" audioUrl = "https://example.com/audio.mp3"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -718,8 +736,8 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with video URL containing special chars produces valid JSON`() { fun `build with video URL containing special chars produces valid JSON`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
"https://example.com/video?id=1&name=\"test\"", null videoUrl = "https://example.com/video?id=1&name=\"test\""
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -730,7 +748,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with file card produces valid JSON`() { fun `build with file card produces valid JSON`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Post text", emptyList(), null, null, null, null, "Post text",
fileUrl = "https://example.com/files/report.pdf", fileUrl = "https://example.com/files/report.pdf",
fileName = "report.pdf", fileName = "report.pdf",
fileSize = 102400 fileSize = 102400
@ -742,7 +760,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with file card includes file type`() { fun `build with file card includes file type`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
fileUrl = "https://example.com/files/doc.pdf", fileUrl = "https://example.com/files/doc.pdf",
fileName = "doc.pdf", fileName = "doc.pdf",
fileSize = 5000 fileSize = 5000
@ -757,7 +775,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with file card includes src fileName and fileSize`() { fun `build with file card includes src fileName and fileSize`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
fileUrl = "https://example.com/files/report.pdf", fileUrl = "https://example.com/files/report.pdf",
fileName = "report.pdf", fileName = "report.pdf",
fileSize = 204800 fileSize = 204800
@ -773,7 +791,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with file card has correct section count`() { fun `build with file card has correct section count`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
fileUrl = "https://example.com/file.pdf", fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf", fileName = "file.pdf",
fileSize = 1000 fileSize = 1000
@ -787,7 +805,11 @@ class MobiledocBuilderTest {
fun `build with file card comes after image and bookmark cards`() { fun `build with file card comes after image and bookmark cards`() {
val images = listOf("https://example.com/img.jpg") val images = listOf("https://example.com/img.jpg")
val result = MobiledocBuilder.build( 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", fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf", fileName = "file.pdf",
fileSize = 1024 fileSize = 1024
@ -805,7 +827,10 @@ class MobiledocBuilderTest {
fun `build with file card and all attachments has correct section count`() { 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 images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build( 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", fileUrl = "https://example.com/file.pdf",
fileName = "file.pdf", fileName = "file.pdf",
fileSize = 500 fileSize = 500
@ -817,10 +842,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build without file produces no file card`() { fun `build without file produces no file card`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build("Text")
"Text", emptyList(), null, null, null, null,
fileUrl = null, fileName = null, fileSize = 0
)
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertTrue(json.getAsJsonArray("cards").isEmpty) assertTrue(json.getAsJsonArray("cards").isEmpty)
} }
@ -828,7 +850,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build with file card escapes fileName`() { fun `build with file card escapes fileName`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null, "Text",
fileUrl = "https://example.com/file.pdf", fileUrl = "https://example.com/file.pdf",
fileName = "my \"special\" file.pdf", fileName = "my \"special\" file.pdf",
fileSize = 100 fileSize = 100