mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
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:
parent
29927a7638
commit
da8a90470d
13 changed files with 305 additions and 376 deletions
|
|
@ -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<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
|
||||
)
|
||||
return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = linkPreview?.description)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,11 +20,11 @@ object MobiledocBuilder {
|
|||
*/
|
||||
fun build(
|
||||
text: String,
|
||||
imageUrls: List<String>,
|
||||
linkUrl: String?,
|
||||
linkTitle: String?,
|
||||
linkDescription: String?,
|
||||
imageAlt: String?,
|
||||
imageUrls: List<String> = emptyList(),
|
||||
linkUrl: String? = null,
|
||||
linkTitle: String? = null,
|
||||
linkDescription: String? = null,
|
||||
imageAlt: String? = null,
|
||||
videoUrl: String? = null,
|
||||
audioUrl: String? = null,
|
||||
fileUrl: String? = null,
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc)
|
||||
val mobiledocCards = parseMobiledocCards(mobiledoc)
|
||||
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
|
||||
val allImages = mutableListOf<String>()
|
||||
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<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 {
|
||||
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) {
|
||||
emptyList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<FeedPost>()
|
||||
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<FeedPost>()
|
||||
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() }
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
emptyList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue