mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add file attachment support in Composer, MobiledocBuilder, and PostUploadWorker
- Add file card support to MobiledocBuilder with Ghost's native file card format - Add file card tests to MobiledocBuilderTest - Add file state fields to ComposerUiState (fileUri, fileName, fileSize, fileMimeType, uploadedFileUrl) - Add addFile()/removeFile() methods to ComposerViewModel with 50MB size validation - Add file picker button and FileAttachmentComposerCard in ComposerScreen - Update PostUploadWorker to upload files via repository.uploadFile() and include in mobiledoc
This commit is contained in:
parent
2f9b7dac09
commit
74dac1db6f
5 changed files with 339 additions and 10 deletions
|
|
@ -50,10 +50,11 @@ object MobiledocBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first)
|
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
|
||||||
* and an optional link preview.
|
* an optional link preview, and an optional file attachment.
|
||||||
* Each image becomes an image card in the mobiledoc format.
|
* Each image becomes an image card in the mobiledoc format.
|
||||||
* The bookmark card (link preview) is added after image cards.
|
* The bookmark card (link preview) is added after image cards.
|
||||||
|
* The file card is added last, after all other cards.
|
||||||
*/
|
*/
|
||||||
fun build(
|
fun build(
|
||||||
text: String,
|
text: String,
|
||||||
|
|
@ -61,7 +62,10 @@ object MobiledocBuilder {
|
||||||
linkUrl: String?,
|
linkUrl: String?,
|
||||||
linkTitle: String?,
|
linkTitle: String?,
|
||||||
linkDescription: String?,
|
linkDescription: String?,
|
||||||
imageAlt: String?
|
imageAlt: String?,
|
||||||
|
fileUrl: String? = null,
|
||||||
|
fileName: String? = null,
|
||||||
|
fileSize: Long = 0
|
||||||
): String {
|
): String {
|
||||||
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
||||||
|
|
||||||
|
|
@ -86,6 +90,14 @@ object MobiledocBuilder {
|
||||||
cardSections.add("[10,${cards.size - 1}]")
|
cardSections.add("[10,${cards.size - 1}]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add file card if file is present
|
||||||
|
if (fileUrl != null) {
|
||||||
|
val escapedFileUrl = escapeForJson(fileUrl)
|
||||||
|
val escapedFileName = fileName?.let { escapeForJson(it) } ?: ""
|
||||||
|
cards.add("""["file",{"src":"$escapedFileUrl","fileName":"$escapedFileName","fileSize":$fileSize}]""")
|
||||||
|
cardSections.add("[10,${cards.size - 1}]")
|
||||||
|
}
|
||||||
|
|
||||||
val cardsJson = cards.joinToString(",")
|
val cardsJson = cards.joinToString(",")
|
||||||
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.OffsetMapping
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
import androidx.compose.ui.text.input.TransformedText
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
@ -110,8 +111,17 @@ fun ComposerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File picker
|
||||||
|
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
viewModel.addFile(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val canSubmit by remember {
|
val canSubmit by remember {
|
||||||
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) }
|
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -384,6 +394,12 @@ fun ComposerScreen(
|
||||||
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
||||||
Icon(Icons.Default.Link, "Add link")
|
Icon(Icons.Default.Link, "Add link")
|
||||||
}
|
}
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = { filePickerLauncher.launch("application/*") },
|
||||||
|
enabled = state.fileUri == null
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.AttachFile, "Attach file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image grid preview (multi-image) — 120dp thumbnails
|
// Image grid preview (multi-image) — 120dp thumbnails
|
||||||
|
|
@ -433,6 +449,23 @@ fun ComposerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File attachment card
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.fileUri != null,
|
||||||
|
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
||||||
|
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
FileAttachmentComposerCard(
|
||||||
|
fileName = state.fileName ?: "file",
|
||||||
|
fileSize = state.fileSize ?: 0,
|
||||||
|
fileMimeType = state.fileMimeType,
|
||||||
|
onRemove = viewModel::removeFile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Link preview
|
// Link preview
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isLoadingLink,
|
visible = state.isLoadingLink,
|
||||||
|
|
@ -1008,3 +1041,82 @@ 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.
|
||||||
|
*/
|
||||||
|
private fun formatFileSize(bytes: Long): String {
|
||||||
|
return when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||||
|
else -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File attachment card shown in the composer with file info and remove button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FileAttachmentComposerCard(
|
||||||
|
fileName: String,
|
||||||
|
fileSize: Long,
|
||||||
|
fileMimeType: String?,
|
||||||
|
onRemove: () -> Unit
|
||||||
|
) {
|
||||||
|
val iconTint = fileTypeColor(fileMimeType)
|
||||||
|
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.InsertDriveFile,
|
||||||
|
contentDescription = "File",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = iconTint
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = fileName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (fileSize > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatFileSize(fileSize),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove file",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,57 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a file to the post. Reads filename and size from ContentResolver.
|
||||||
|
* Validates type and size (max 50 MB).
|
||||||
|
*/
|
||||||
|
fun addFile(uri: Uri) {
|
||||||
|
val contentResolver = appContext.contentResolver
|
||||||
|
var name: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
|
||||||
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||||
|
if (nameIndex >= 0) name = cursor.getString(nameIndex)
|
||||||
|
if (sizeIndex >= 0) size = cursor.getLong(sizeIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||||
|
|
||||||
|
// Validate size: max 50 MB
|
||||||
|
val maxSize = 50L * 1024 * 1024
|
||||||
|
if (size != null && size!! > maxSize) {
|
||||||
|
_uiState.update { it.copy(error = "File too large. Maximum file size is 50 MB.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
fileUri = uri,
|
||||||
|
fileName = name ?: "file",
|
||||||
|
fileSize = size,
|
||||||
|
fileMimeType = mimeType,
|
||||||
|
uploadedFileUrl = null,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFile() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
fileUri = null,
|
||||||
|
fileName = null,
|
||||||
|
fileSize = null,
|
||||||
|
fileMimeType = null,
|
||||||
|
uploadedFileUrl = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchLinkPreview(url: String) {
|
fun fetchLinkPreview(url: String) {
|
||||||
if (url.isBlank()) return
|
if (url.isBlank()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -234,7 +285,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.text.isBlank() && state.imageUris.isEmpty()) return
|
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||||
|
|
@ -265,7 +316,9 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
linkImageUrl = state.linkPreview?.imageUrl,
|
linkImageUrl = state.linkPreview?.imageUrl,
|
||||||
scheduledAt = state.scheduledAt,
|
scheduledAt = state.scheduledAt,
|
||||||
tags = tagsJson,
|
tags = tagsJson,
|
||||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
|
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus,
|
||||||
|
fileUri = state.fileUri?.toString(),
|
||||||
|
fileName = state.fileName
|
||||||
)
|
)
|
||||||
repository.saveLocalPost(localPost)
|
repository.saveLocalPost(localPost)
|
||||||
|
|
||||||
|
|
@ -290,12 +343,28 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload file if attached
|
||||||
|
var uploadedFileUrl = state.uploadedFileUrl
|
||||||
|
if (state.fileUri != null && uploadedFileUrl == null) {
|
||||||
|
val fileResult = repository.uploadFile(state.fileUri)
|
||||||
|
fileResult.fold(
|
||||||
|
onSuccess = { url -> uploadedFileUrl = url },
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(isSubmitting = false, error = "File upload failed: ${e.message}") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val featureImage = uploadedImageUrls.firstOrNull()
|
val featureImage = uploadedImageUrls.firstOrNull()
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
state.text, uploadedImageUrls,
|
state.text, uploadedImageUrls,
|
||||||
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||||
altText
|
altText,
|
||||||
|
fileUrl = uploadedFileUrl,
|
||||||
|
fileName = state.fileName,
|
||||||
|
fileSize = state.fileSize ?: 0
|
||||||
)
|
)
|
||||||
val ghostTags = allTags.map { GhostTag(name = it) }
|
val ghostTags = allTags.map { GhostTag(name = it) }
|
||||||
|
|
||||||
|
|
@ -342,7 +411,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
linkImageUrl = state.linkPreview?.imageUrl,
|
linkImageUrl = state.linkPreview?.imageUrl,
|
||||||
scheduledAt = state.scheduledAt,
|
scheduledAt = state.scheduledAt,
|
||||||
tags = tagsJson,
|
tags = tagsJson,
|
||||||
queueStatus = offlineQueueStatus
|
queueStatus = offlineQueueStatus,
|
||||||
|
fileUri = state.fileUri?.toString(),
|
||||||
|
uploadedFileUrl = uploadedFileUrl,
|
||||||
|
fileName = state.fileName
|
||||||
)
|
)
|
||||||
repository.saveLocalPost(localPost)
|
repository.saveLocalPost(localPost)
|
||||||
PostUploadWorker.enqueue(appContext)
|
PostUploadWorker.enqueue(appContext)
|
||||||
|
|
@ -382,7 +454,13 @@ data class ComposerUiState(
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isPreviewMode: Boolean = false,
|
val isPreviewMode: Boolean = false,
|
||||||
val previewHtml: String = ""
|
val previewHtml: String = "",
|
||||||
|
// File attachment
|
||||||
|
val fileUri: Uri? = null,
|
||||||
|
val fileName: String? = null,
|
||||||
|
val fileSize: Long? = null,
|
||||||
|
val fileMimeType: String? = null,
|
||||||
|
val uploadedFileUrl: String? = null
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Backwards compatibility: returns the first image URI or null.
|
* Backwards compatibility: returns the first image URI or null.
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,24 @@ class PostUploadWorker(
|
||||||
featureImage = allImageUrls.first()
|
featureImage = allImageUrls.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload file if needed
|
||||||
|
var uploadedFileUrl = post.uploadedFileUrl
|
||||||
|
if (uploadedFileUrl == null && post.fileUri != null) {
|
||||||
|
val fileResult = repository.uploadFile(Uri.parse(post.fileUri))
|
||||||
|
if (fileResult.isFailure) {
|
||||||
|
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
|
||||||
|
allSuccess = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uploadedFileUrl = fileResult.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||||
post.imageAlt
|
post.imageAlt,
|
||||||
|
fileUrl = uploadedFileUrl,
|
||||||
|
fileName = post.fileName,
|
||||||
|
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse tags from JSON stored in LocalPost
|
// Parse tags from JSON stored in LocalPost
|
||||||
|
|
|
||||||
|
|
@ -597,4 +597,116 @@ class MobiledocBuilderTest {
|
||||||
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
|
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
|
||||||
assertEquals("", secondCard.get("alt").asString)
|
assertEquals("", secondCard.get("alt").asString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- File card tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with file card produces valid JSON`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Post text", emptyList(), null, null, null, null,
|
||||||
|
fileUrl = "https://example.com/files/report.pdf",
|
||||||
|
fileName = "report.pdf",
|
||||||
|
fileSize = 102400
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
assertNotNull(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with file card includes file type`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", emptyList(), null, null, null, null,
|
||||||
|
fileUrl = "https://example.com/files/doc.pdf",
|
||||||
|
fileName = "doc.pdf",
|
||||||
|
fileSize = 5000
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
assertEquals(1, cards.size())
|
||||||
|
val card = cards.get(0).asJsonArray
|
||||||
|
assertEquals("file", card.get(0).asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with file card includes src fileName and fileSize`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", emptyList(), null, null, null, null,
|
||||||
|
fileUrl = "https://example.com/files/report.pdf",
|
||||||
|
fileName = "report.pdf",
|
||||||
|
fileSize = 204800
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
val cardData = cards.get(0).asJsonArray.get(1).asJsonObject
|
||||||
|
assertEquals("https://example.com/files/report.pdf", cardData.get("src").asString)
|
||||||
|
assertEquals("report.pdf", cardData.get("fileName").asString)
|
||||||
|
assertEquals(204800, cardData.get("fileSize").asLong)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with file card has correct section count`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", emptyList(), null, null, null, null,
|
||||||
|
fileUrl = "https://example.com/file.pdf",
|
||||||
|
fileName = "file.pdf",
|
||||||
|
fileSize = 1000
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
assertEquals("Should have text section and file card section", 2, sections.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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",
|
||||||
|
fileUrl = "https://example.com/file.pdf",
|
||||||
|
fileName = "file.pdf",
|
||||||
|
fileSize = 1024
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
assertEquals(3, cards.size())
|
||||||
|
// Image first, bookmark second, file last
|
||||||
|
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
|
||||||
|
assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString)
|
||||||
|
assertEquals("file", cards.get(2).asJsonArray.get(0).asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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,
|
||||||
|
fileUrl = "https://example.com/file.pdf",
|
||||||
|
fileName = "file.pdf",
|
||||||
|
fileSize = 500
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
// 1 text + 2 image + 1 bookmark + 1 file = 5
|
||||||
|
assertEquals(5, json.getAsJsonArray("sections").size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 json = JsonParser.parseString(result).asJsonObject
|
||||||
|
assertTrue(json.getAsJsonArray("cards").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with file card escapes fileName`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", emptyList(), null, null, null, null,
|
||||||
|
fileUrl = "https://example.com/file.pdf",
|
||||||
|
fileName = "my \"special\" file.pdf",
|
||||||
|
fileSize = 100
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
assertNotNull("Should produce valid JSON with escaped file name", json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue