merge: integrate hashtag support (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 11:09:30 +01:00
commit 91982a66a2
No known key found for this signature in database
13 changed files with 733 additions and 48 deletions

View file

@ -0,0 +1,78 @@
package com.swoosh.microblog.data
/**
* Parses #hashtag patterns from post text content.
*
* Rules:
* - Hashtags start with # followed by at least one letter or digit
* - Allowed characters: letters, digits, hyphens, underscores
* - Hashtags must NOT be inside URLs (http://, https://, or www.)
* - Leading # is stripped from the result
* - Results are unique (case-preserved, deduped case-insensitively)
* - Trailing hyphens/underscores are trimmed from tag names
*/
object HashtagParser {
// Matches URLs so we can skip hashtags that appear inside them
private val URL_PATTERN = Regex(
"""(?:https?://|www\.)\S+""",
RegexOption.IGNORE_CASE
)
// Matches a hashtag: # followed by at least one word char (letter/digit),
// then optionally more word chars or hyphens
private val HASHTAG_PATTERN = Regex(
"""#([\w][\w-]*)""",
RegexOption.IGNORE_CASE
)
/**
* Extracts unique hashtag names from text content.
* Returns tag names without the # prefix.
*/
fun parse(text: String): List<String> {
if (text.isBlank()) return emptyList()
// Find all URL ranges so we can exclude hashtags inside URLs
val urlRanges = URL_PATTERN.findAll(text).map { it.range }.toList()
val seen = mutableSetOf<String>()
val result = mutableListOf<String>()
for (match in HASHTAG_PATTERN.findAll(text)) {
// Skip if this hashtag is inside a URL
if (urlRanges.any { urlRange -> match.range.first in urlRange }) {
continue
}
val tagName = match.groupValues[1].trimEnd('-', '_')
// Must have at least one character after trimming
if (tagName.isEmpty()) continue
// Deduplicate case-insensitively but preserve original casing
val lowerTag = tagName.lowercase()
if (lowerTag !in seen) {
seen.add(lowerTag)
result.add(tagName)
}
}
return result
}
/**
* Returns the ranges of hashtags in the text for highlighting.
* Each range covers the full hashtag including the # prefix.
*/
fun findHashtagRanges(text: String): List<IntRange> {
if (text.isBlank()) return emptyList()
val urlRanges = URL_PATTERN.findAll(text).map { it.range }.toList()
return HASHTAG_PATTERN.findAll(text)
.filter { match -> urlRanges.none { urlRange -> match.range.first in urlRange } }
.map { it.range }
.toList()
}
}

View file

@ -13,7 +13,7 @@ interface GhostApiService {
suspend fun getPosts(
@Query("limit") limit: Int = 15,
@Query("page") page: Int = 1,
@Query("include") include: String = "authors",
@Query("include") include: String = "authors,tags",
@Query("formats") formats: String = "html,plaintext,mobiledoc",
@Query("order") order: String = "created_at desc",
@Query("filter") filter: String? = null

View file

@ -23,6 +23,7 @@ abstract class AppDatabase : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
}
}

View file

@ -46,7 +46,14 @@ data class GhostPost(
val custom_excerpt: String? = null,
val visibility: String? = "public",
val authors: List<Author>? = null,
val reading_time: Int? = null
val reading_time: Int? = null,
val tags: List<GhostTag>? = null
)
data class GhostTag(
val id: String? = null,
val name: String,
val slug: String? = null
)
data class Author(
@ -74,6 +81,7 @@ data class LocalPost(
val linkImageUrl: String? = null,
val imageAlt: String? = null,
val scheduledAt: String? = null,
val tags: String = "[]",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val queueStatus: QueueStatus = QueueStatus.NONE
@ -109,6 +117,7 @@ data class FeedPost(
val linkTitle: String?,
val linkDescription: String?,
val linkImageUrl: String?,
val tags: List<String> = emptyList(),
val status: String,
val featured: Boolean = false,
val publishedAt: String?,

View file

@ -36,15 +36,22 @@ class PostRepository(private val context: Context) {
page: Int = 1,
limit: Int = 15,
filter: PostFilter = PostFilter.ALL,
sortOrder: SortOrder = SortOrder.NEWEST
sortOrder: SortOrder = SortOrder.NEWEST,
tagFilter: String? = null
): Result<PostsResponse> =
withContext(Dispatchers.IO) {
try {
// Combine status filter and tag filter
val filterParts = mutableListOf<String>()
filter.ghostFilter?.let { filterParts.add(it) }
tagFilter?.let { filterParts.add("tag:$it") }
val combinedFilter = filterParts.takeIf { it.isNotEmpty() }?.joinToString("+")
val response = getApi().getPosts(
limit = limit,
page = page,
order = sortOrder.ghostOrder,
filter = filter.ghostFilter
filter = combinedFilter
)
if (response.isSuccessful) {
Result.success(response.body()!!)

View file

@ -16,15 +16,22 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
@ -96,6 +103,12 @@ fun ComposerScreen(
.fillMaxSize()
.padding(padding)
) {
// Hashtag visual transformation for edit mode text field
val hashtagColor = MaterialTheme.colorScheme.primary
val hashtagTransformation = remember(hashtagColor) {
HashtagVisualTransformation(hashtagColor)
}
// Edit / Preview segmented button row
SingleChoiceSegmentedButtonRow(
modifier = Modifier
@ -168,7 +181,7 @@ fun ComposerScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Text field with character counter
// Text field with character counter and hashtag highlighting
OutlinedTextField(
value = state.text,
onValueChange = viewModel::updateText,
@ -176,6 +189,7 @@ fun ComposerScreen(
.fillMaxWidth()
.heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") },
visualTransformation = hashtagTransformation,
supportingText = {
val charCount = state.text.length
val statsText = PostStats.formatComposerStats(state.text)
@ -192,6 +206,44 @@ fun ComposerScreen(
}
)
// Extracted tags preview chips
if (state.extractedTags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.Tag,
contentDescription = "Tags",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
state.extractedTags.forEach { tag ->
SuggestionChip(
onClick = {},
label = {
Text(
"#$tag",
style = MaterialTheme.typography.labelSmall
)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
),
border = null
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Attachment buttons row
@ -550,3 +602,28 @@ fun ScheduleDateTimePicker(
)
}
}
/**
* VisualTransformation that highlights #hashtags in a different color.
*/
class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTransformation {
override fun filter(text: androidx.compose.ui.text.AnnotatedString): TransformedText {
val ranges = HashtagParser.findHashtagRanges(text.text)
if (ranges.isEmpty()) {
return TransformedText(text, OffsetMapping.Identity)
}
val annotated = buildAnnotatedString {
append(text.text)
for (range in ranges) {
val safeEnd = minOf(range.last + 1, text.text.length)
addStyle(
SpanStyle(color = hashtagColor),
start = range.first,
end = safeEnd
)
}
}
return TransformedText(annotated, OffsetMapping.Identity)
}
}

View file

@ -4,12 +4,15 @@ import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.OpenGraphFetcher
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.worker.PostUploadWorker
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -48,13 +51,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
imageUrl = post.linkImageUrl
) else null,
featured = post.featured,
extractedTags = HashtagParser.parse(post.textContent),
isEditing = true
)
}
}
fun updateText(text: String) {
_uiState.update { it.copy(text = text) }
val extractedTags = HashtagParser.parse(text)
_uiState.update { it.copy(text = text, extractedTags = extractedTags) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
@ -161,6 +166,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(isSubmitting = true, error = null) }
val title = state.text.take(60)
val extractedTags = HashtagParser.parse(state.text)
val tagsJson = Gson().toJson(extractedTags)
val altText = state.imageAlt.ifBlank { null }
@ -180,6 +187,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt,
tags = tagsJson,
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
)
repository.saveLocalPost(localPost)
@ -212,6 +220,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
featureImage,
altText
)
val ghostTags = extractedTags.map { GhostTag(name = it) }
val ghostPost = GhostPost(
title = title,
@ -221,7 +230,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
feature_image = featureImage,
feature_image_alt = altText,
published_at = state.scheduledAt,
visibility = "public"
visibility = "public",
tags = ghostTags.ifEmpty { null }
)
val result = if (editingGhostId != null) {
@ -252,6 +262,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt,
tags = tagsJson,
queueStatus = offlineQueueStatus
)
repository.saveLocalPost(localPost)
@ -283,6 +294,7 @@ data class ComposerUiState(
val isLoadingLink: Boolean = false,
val scheduledAt: String? = null,
val featured: Boolean = false,
val extractedTags: List<String> = emptyList(),
val isSubmitting: Boolean = false,
val isSuccess: Boolean = false,
val isEditing: Boolean = false,

View file

@ -50,7 +50,7 @@ import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DetailScreen(
post: FeedPost,
@ -202,6 +202,29 @@ fun DetailScreen(
style = MaterialTheme.typography.bodyLarge
)
// Tags
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
SuggestionChip(
onClick = {},
label = {
Text("#$tag", style = MaterialTheme.typography.labelMedium)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
),
border = null
)
}
}
}
// Full image
if (post.imageUrl != null) {
Spacer(modifier = Modifier.height(16.dp))

View file

@ -23,20 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.PushPin
@ -225,6 +211,23 @@ fun FeedScreen(
)
}
// Active tag filter bar
if (state.activeTagFilter != null) {
FilterChip(
onClick = { viewModel.clearTagFilter() },
label = { Text("#${state.activeTagFilter}") },
selected = true,
leadingIcon = {
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp))
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Show recent searches when search is active but query is empty
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
RecentSearchesList(
@ -415,6 +418,7 @@ fun FeedScreen(
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState
)
}
@ -455,6 +459,7 @@ fun FeedScreen(
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState
)
}
@ -663,6 +668,7 @@ fun SwipeablePostCard(
onEdit: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit = {},
onTagClick: (String) -> Unit = {},
snackbarHostState: SnackbarHostState? = null
) {
val dismissState = rememberSwipeToDismissBoxState(
@ -713,6 +719,7 @@ fun SwipeablePostCard(
onEdit = onEdit,
onDelete = onDelete,
onTogglePin = onTogglePin,
onTagClick = onTagClick,
snackbarHostState = snackbarHostState
)
}
@ -790,7 +797,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SearchTopBar(
query: String,
@ -917,6 +924,7 @@ fun PostCardContent(
onEdit: () -> Unit = {},
onDelete: () -> Unit = {},
onTogglePin: () -> Unit = {},
onTagClick: (String) -> Unit = {},
snackbarHostState: SnackbarHostState? = null,
highlightQuery: String? = null
) {
@ -1060,6 +1068,30 @@ fun PostCardContent(
}
}
// Hashtag chips
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
SuggestionChip(
onClick = { onTagClick(tag) },
label = {
Text("#$tag", style = MaterialTheme.typography.labelSmall)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
),
border = null
)
}
}
}
// Link preview
if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(8.dp))

View file

@ -7,8 +7,11 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.FeedPreferences
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
@ -205,8 +208,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val filter = _activeFilter.value
val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort).fold(
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() }
hasMorePages = response.meta?.pagination?.next != null
@ -221,6 +225,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun filterByTag(tag: String) {
_uiState.update { it.copy(activeTagFilter = tag) }
refresh()
}
fun clearTagFilter() {
_uiState.update { it.copy(activeTagFilter = null) }
refresh()
}
private fun classifyError(e: Throwable): Pair<String, Boolean> {
val cause = e.cause ?: e
return when {
@ -249,8 +263,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val filter = _activeFilter.value
val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort).fold(
repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response ->
val newPosts = response.posts.map { it.toFeedPost() }
remotePosts = remotePosts + newPosts
@ -385,8 +400,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
val tagFilter = _uiState.value.activeTagFilter
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
val allPosts = queued + remotePosts
// Filter local posts by tag if a tag filter is active
val filteredQueued = if (tagFilter != null) {
queued.filter { post ->
post.tags.any { it.equals(tagFilter, ignoreCase = true) }
}
} else queued
val allPosts = filteredQueued + remotePosts
// Sort: featured/pinned posts first, then chronological
val sorted = allPosts.sortedWith(
compareByDescending<FeedPost> { it.featured }
@ -409,6 +431,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
tags = tags?.map { it.name } ?: emptyList(),
status = status ?: "draft",
featured = featured ?: false,
publishedAt = published_at,
@ -417,7 +440,13 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = false
)
private fun LocalPost.toFeedPost(): FeedPost = FeedPost(
private fun LocalPost.toFeedPost(): FeedPost {
val tagNames: List<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
return FeedPost(
localId = localId,
ghostId = ghostId,
title = title,
@ -429,6 +458,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = linkTitle,
linkDescription = linkDescription,
linkImageUrl = linkImageUrl,
tags = tagNames,
status = status.name.lowercase(),
featured = featured,
publishedAt = null,
@ -437,6 +467,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = true,
queueStatus = queueStatus
)
}
companion object {
/**
@ -468,7 +499,8 @@ data class FeedUiState(
val isLoadingMore: Boolean = false,
val error: String? = null,
val isConnectionError: Boolean = false,
val snackbarMessage: String? = null
val snackbarMessage: String? = null,
val activeTagFilter: String? = null
)
/**

View file

@ -5,8 +5,11 @@ import android.net.Uri
import androidx.work.*
import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.model.GhostPost
import com.swoosh.microblog.data.model.GhostTag
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.repository.PostRepository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.concurrent.TimeUnit
class PostUploadWorker(
@ -42,6 +45,14 @@ class PostUploadWorker(
featureImage, post.imageAlt
)
// Parse tags from JSON stored in LocalPost
val tagNames: List<String> = try {
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
val ghostTags = tagNames.map { GhostTag(name = it) }
val ghostPost = GhostPost(
title = post.title,
mobiledoc = mobiledoc,
@ -54,7 +65,8 @@ class PostUploadWorker(
feature_image = featureImage,
feature_image_alt = post.imageAlt,
published_at = post.scheduledAt,
visibility = "public"
visibility = "public",
tags = ghostTags.ifEmpty { null }
)
val result = if (post.ghostId != null) {

View file

@ -0,0 +1,218 @@
package com.swoosh.microblog.data
import org.junit.Assert.*
import org.junit.Test
class HashtagParserTest {
// --- Basic parsing ---
@Test
fun `parse extracts single hashtag`() {
val tags = HashtagParser.parse("Hello #world")
assertEquals(listOf("world"), tags)
}
@Test
fun `parse extracts multiple hashtags`() {
val tags = HashtagParser.parse("Hello #world #kotlin #android")
assertEquals(listOf("world", "kotlin", "android"), tags)
}
@Test
fun `parse returns empty list for text without hashtags`() {
val tags = HashtagParser.parse("Hello world, no tags here!")
assertTrue(tags.isEmpty())
}
@Test
fun `parse returns empty list for blank text`() {
val tags = HashtagParser.parse("")
assertTrue(tags.isEmpty())
val tags2 = HashtagParser.parse(" ")
assertTrue(tags2.isEmpty())
}
@Test
fun `parse strips hash prefix from results`() {
val tags = HashtagParser.parse("#kotlin")
assertEquals("kotlin", tags.first())
assertFalse(tags.first().startsWith("#"))
}
// --- Position handling ---
@Test
fun `parse handles hashtag at start of text`() {
val tags = HashtagParser.parse("#hello this is a post")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse handles hashtag at end of text`() {
val tags = HashtagParser.parse("This is a post #hello")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse handles hashtag in middle of text`() {
val tags = HashtagParser.parse("This is a #hello post")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse handles hashtags on multiple lines`() {
val tags = HashtagParser.parse("Line one #tag1\nLine two #tag2\nLine three #tag3")
assertEquals(listOf("tag1", "tag2", "tag3"), tags)
}
// --- Special characters ---
@Test
fun `parse handles CamelCase hashtags`() {
val tags = HashtagParser.parse("Hello #CamelCase and #helloWorld")
assertEquals(listOf("CamelCase", "helloWorld"), tags)
}
@Test
fun `parse handles hyphenated hashtags`() {
val tags = HashtagParser.parse("Check out #hello-world")
assertEquals(listOf("hello-world"), tags)
}
@Test
fun `parse handles underscored hashtags`() {
val tags = HashtagParser.parse("Check out #hello_world")
assertEquals(listOf("hello_world"), tags)
}
@Test
fun `parse handles hashtags with digits`() {
val tags = HashtagParser.parse("Check out #tag123 and #2024goals")
assertEquals(listOf("tag123", "2024goals"), tags)
}
@Test
fun `parse trims trailing hyphens from tag names`() {
val tags = HashtagParser.parse("Check #hello- there")
assertEquals(listOf("hello"), tags)
}
@Test
fun `parse trims trailing underscores from tag names`() {
val tags = HashtagParser.parse("Check #hello_ there")
assertEquals(listOf("hello"), tags)
}
// --- Deduplication ---
@Test
fun `parse deduplicates tags case-insensitively`() {
val tags = HashtagParser.parse("#Hello #hello #HELLO")
assertEquals(1, tags.size)
assertEquals("Hello", tags.first()) // Preserves first occurrence casing
}
@Test
fun `parse preserves order of unique tags`() {
val tags = HashtagParser.parse("#beta #alpha #gamma")
assertEquals(listOf("beta", "alpha", "gamma"), tags)
}
// --- URL exclusion ---
@Test
fun `parse ignores hashtags inside URLs with https`() {
val tags = HashtagParser.parse("Visit https://example.com/page#section and also #realtag")
assertEquals(listOf("realtag"), tags)
}
@Test
fun `parse ignores hashtags inside URLs with http`() {
val tags = HashtagParser.parse("Visit http://example.com/path#anchor and #realtag")
assertEquals(listOf("realtag"), tags)
}
@Test
fun `parse ignores hashtags inside www URLs`() {
val tags = HashtagParser.parse("Visit www.example.com/path#anchor for more #info")
assertEquals(listOf("info"), tags)
}
@Test
fun `parse handles URL followed by real hashtag`() {
val tags = HashtagParser.parse("https://example.com #kotlin")
assertEquals(listOf("kotlin"), tags)
}
// --- Edge cases ---
@Test
fun `parse ignores lone hash symbol`() {
val tags = HashtagParser.parse("Use # wisely")
assertTrue(tags.isEmpty())
}
@Test
fun `parse handles adjacent hashtags`() {
val tags = HashtagParser.parse("#one#two#three")
// #one is parsed, then #two and #three appear inside #one's text or as separate matches
assertTrue(tags.contains("one"))
}
@Test
fun `parse handles hashtag with punctuation after it`() {
val tags = HashtagParser.parse("Check #kotlin, it's great! And #android.")
assertTrue(tags.contains("kotlin"))
assertTrue(tags.contains("android"))
}
@Test
fun `parse handles hashtag in parentheses`() {
val tags = HashtagParser.parse("Languages (#kotlin) are great")
assertTrue(tags.contains("kotlin"))
}
@Test
fun `parse handles real-world microblog post`() {
val text = "Just shipped a new feature for Swoosh! #android #ghostcms #opensource\n\nCheck it out at https://github.com/example/swoosh#readme"
val tags = HashtagParser.parse(text)
assertEquals(3, tags.size)
assertTrue(tags.contains("android"))
assertTrue(tags.contains("ghostcms"))
assertTrue(tags.contains("opensource"))
// Should NOT include 'readme' from the URL
assertFalse(tags.contains("readme"))
}
// --- findHashtagRanges ---
@Test
fun `findHashtagRanges returns correct ranges`() {
val text = "Hello #world and #kotlin"
val ranges = HashtagParser.findHashtagRanges(text)
assertEquals(2, ranges.size)
assertEquals("#world", text.substring(ranges[0]))
assertEquals("#kotlin", text.substring(ranges[1]))
}
@Test
fun `findHashtagRanges returns empty for no hashtags`() {
val ranges = HashtagParser.findHashtagRanges("No hashtags here")
assertTrue(ranges.isEmpty())
}
@Test
fun `findHashtagRanges excludes URL hashtags`() {
val text = "Visit https://example.com#section and #realtag"
val ranges = HashtagParser.findHashtagRanges(text)
assertEquals(1, ranges.size)
assertEquals("#realtag", text.substring(ranges[0]))
}
@Test
fun `findHashtagRanges returns empty for blank text`() {
val ranges = HashtagParser.findHashtagRanges("")
assertTrue(ranges.isEmpty())
}
}

View file

@ -0,0 +1,184 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.junit.Assert.*
import org.junit.Test
class GhostTagTest {
private val gson = Gson()
@Test
fun `GhostTag serializes to JSON correctly`() {
val tag = GhostTag(name = "kotlin")
val json = gson.toJson(tag)
assertTrue(json.contains("\"name\":\"kotlin\""))
}
@Test
fun `GhostTag serializes with all fields`() {
val tag = GhostTag(id = "tag123", name = "kotlin", slug = "kotlin")
val json = gson.toJson(tag)
assertTrue(json.contains("\"id\":\"tag123\""))
assertTrue(json.contains("\"name\":\"kotlin\""))
assertTrue(json.contains("\"slug\":\"kotlin\""))
}
@Test
fun `GhostTag deserializes from JSON`() {
val json = """{"id":"abc","name":"android","slug":"android"}"""
val tag = gson.fromJson(json, GhostTag::class.java)
assertEquals("abc", tag.id)
assertEquals("android", tag.name)
assertEquals("android", tag.slug)
}
@Test
fun `GhostTag deserializes with only name`() {
val json = """{"name":"test-tag"}"""
val tag = gson.fromJson(json, GhostTag::class.java)
assertNull(tag.id)
assertEquals("test-tag", tag.name)
assertNull(tag.slug)
}
@Test
fun `GhostTag list serializes for API payload`() {
val tags = listOf(
GhostTag(name = "kotlin"),
GhostTag(name = "android"),
GhostTag(name = "ghost-cms")
)
val json = gson.toJson(tags)
assertTrue(json.contains("kotlin"))
assertTrue(json.contains("android"))
assertTrue(json.contains("ghost-cms"))
}
@Test
fun `GhostTag list deserializes from API response`() {
val json = """[{"id":"1","name":"kotlin","slug":"kotlin"},{"id":"2","name":"android","slug":"android"}]"""
val type = object : TypeToken<List<GhostTag>>() {}.type
val tags: List<GhostTag> = gson.fromJson(json, type)
assertEquals(2, tags.size)
assertEquals("kotlin", tags[0].name)
assertEquals("android", tags[1].name)
}
@Test
fun `GhostPost with tags serializes correctly for API`() {
val post = GhostPost(
title = "Test Post",
status = "published",
tags = listOf(GhostTag(name = "kotlin"), GhostTag(name = "android"))
)
val wrapper = PostWrapper(listOf(post))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"tags\""))
assertTrue(json.contains("\"kotlin\""))
assertTrue(json.contains("\"android\""))
}
@Test
fun `GhostPost with null tags serializes without tags`() {
val post = GhostPost(title = "Test Post", tags = null)
val json = gson.toJson(post)
// Gson does not serialize null fields by default
assertFalse(json.contains("\"tags\""))
}
@Test
fun `GhostPost with empty tags list serializes with empty array`() {
val post = GhostPost(title = "Test Post", tags = emptyList())
val json = gson.toJson(post)
assertTrue(json.contains("\"tags\":[]"))
}
@Test
fun `GhostPost deserializes tags from API response`() {
val json = """{
"id": "post1",
"title": "Hello",
"tags": [{"id":"t1","name":"kotlin","slug":"kotlin"},{"id":"t2","name":"android","slug":"android"}]
}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertNotNull(post.tags)
assertEquals(2, post.tags!!.size)
assertEquals("kotlin", post.tags!![0].name)
assertEquals("android", post.tags!![1].name)
}
@Test
fun `GhostPost without tags field deserializes with null tags`() {
val json = """{"id":"post1","title":"Hello"}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertNull(post.tags)
}
@Test
fun `Tag names stored as JSON in LocalPost round-trip correctly`() {
val tagNames = listOf("kotlin", "android", "ghost-cms")
val json = gson.toJson(tagNames)
val type = object : TypeToken<List<String>>() {}.type
val restored: List<String> = gson.fromJson(json, type)
assertEquals(tagNames, restored)
}
@Test
fun `Empty tag list stored as JSON round-trips correctly`() {
val tagNames = emptyList<String>()
val json = gson.toJson(tagNames)
assertEquals("[]", json)
val type = object : TypeToken<List<String>>() {}.type
val restored: List<String> = gson.fromJson(json, type)
assertTrue(restored.isEmpty())
}
@Test
fun `FeedPost default tags is empty list`() {
val post = FeedPost(
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
assertTrue(post.tags.isEmpty())
}
@Test
fun `FeedPost with tags stores them correctly`() {
val post = FeedPost(
title = "Test",
textContent = "Content #kotlin",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
tags = listOf("kotlin", "android"),
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
assertEquals(2, post.tags.size)
assertEquals("kotlin", post.tags[0])
assertEquals("android", post.tags[1])
}
@Test
fun `LocalPost default tags is empty JSON array`() {
val post = LocalPost()
assertEquals("[]", post.tags)
}
}