feat: add hashtag parsing, highlighting, tag chips, and feed filtering by tag

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:11 +01:00
parent 74f42fd2f1
commit 5a41944a97
No known key found for this signature in database
13 changed files with 742 additions and 37 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,9 +13,10 @@ 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("order") order: String = "created_at desc",
@Query("filter") filter: String? = null
): Response<PostsResponse>
@POST("ghost/api/admin/posts/")

View file

@ -5,9 +5,11 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.swoosh.microblog.data.model.LocalPost
@Database(entities = [LocalPost::class], version = 1, exportSchema = false)
@Database(entities = [LocalPost::class], version = 2, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
@Volatile
private var INSTANCE: AppDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
}
}
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"swoosh_database"
).build()
)
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
instance
}

View file

@ -41,7 +41,14 @@ data class GhostPost(
val published_at: String? = null,
val custom_excerpt: String? = null,
val visibility: String? = "public",
val authors: List<Author>? = null
val authors: List<Author>? = null,
val tags: List<GhostTag>? = null
)
data class GhostTag(
val id: String? = null,
val name: String,
val slug: String? = null
)
data class Author(
@ -67,6 +74,7 @@ data class LocalPost(
val linkDescription: String? = null,
val linkImageUrl: String? = null,
val scheduledAt: String? = null,
val tags: String = "[]",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val queueStatus: QueueStatus = QueueStatus.NONE
@ -99,6 +107,7 @@ data class FeedPost(
val linkTitle: String?,
val linkDescription: String?,
val linkImageUrl: String?,
val tags: List<String> = emptyList(),
val status: String,
val publishedAt: String?,
val createdAt: String?,

View file

@ -30,10 +30,11 @@ class PostRepository(private val context: Context) {
// --- Remote operations ---
suspend fun fetchPosts(page: Int = 1, limit: Int = 15): Result<PostsResponse> =
suspend fun fetchPosts(page: Int = 1, limit: Int = 15, tagFilter: String? = null): Result<PostsResponse> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getPosts(limit = limit, page = page)
val filter = tagFilter?.let { "tag:$it" }
val response = getApi().getPosts(limit = limit, page = page, filter = filter)
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {

View file

@ -12,16 +12,24 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.*
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.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
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.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 java.time.LocalDateTime
import java.time.ZoneId
@ -81,7 +89,12 @@ fun ComposerScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Text field with character counter
// Text field with character counter and hashtag highlighting
val hashtagColor = MaterialTheme.colorScheme.primary
val hashtagTransformation = remember(hashtagColor) {
HashtagVisualTransformation(hashtagColor)
}
OutlinedTextField(
value = state.text,
onValueChange = viewModel::updateText,
@ -89,6 +102,7 @@ fun ComposerScreen(
.fillMaxWidth()
.heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") },
visualTransformation = hashtagTransformation,
supportingText = {
Text(
"${state.text.length} characters",
@ -100,6 +114,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
@ -353,3 +405,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,11 +4,14 @@ 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.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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -41,13 +44,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
description = post.linkDescription,
imageUrl = post.linkImageUrl
) else null,
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) }
}
fun setImage(uri: Uri?) {
@ -88,6 +93,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)
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
// Save locally
@ -103,6 +110,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)
@ -128,6 +136,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
}
val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview)
val ghostTags = extractedTags.map { GhostTag(name = it) }
val ghostPost = GhostPost(
title = title,
@ -135,7 +144,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
status = status.name.lowercase(),
feature_image = featureImage,
published_at = state.scheduledAt,
visibility = "public"
visibility = "public",
tags = ghostTags.ifEmpty { null }
)
val result = if (editingGhostId != null) {
@ -164,6 +174,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)
@ -188,6 +199,7 @@ data class ComposerUiState(
val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false,
val scheduledAt: String? = null,
val extractedTags: List<String> = emptyList(),
val isSubmitting: Boolean = false,
val isSuccess: Boolean = false,
val isEditing: Boolean = false,

View file

@ -18,7 +18,7 @@ import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DetailScreen(
post: FeedPost,
@ -76,6 +76,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

@ -8,8 +8,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
@ -86,6 +88,23 @@ fun FeedScreen(
.padding(padding)
.pullRefresh(pullRefreshState)
) {
// 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)
)
}
if (state.posts.isEmpty() && !state.isRefreshing) {
if (state.isConnectionError && state.error != null) {
// Connection error empty state
@ -145,13 +164,17 @@ fun FeedScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
contentPadding = PaddingValues(
top = if (state.activeTagFilter != null) 40.dp else 8.dp,
bottom = 8.dp
)
) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) }
)
}
@ -191,11 +214,13 @@ fun FeedScreen(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PostCard(
post: FeedPost,
onClick: () -> Unit,
onCancelQueue: () -> Unit
onCancelQueue: () -> Unit,
onTagClick: (String) -> Unit = {}
) {
var expanded by remember { mutableStateOf(false) }
val displayText = if (expanded || post.textContent.length <= 280) {
@ -262,6 +287,29 @@ fun PostCard(
)
}
// Hashtag chips
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
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

@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.feed
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
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.flow.*
import kotlinx.coroutines.launch
import java.net.ConnectException
@ -49,7 +52,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
currentPage = 1
hasMorePages = true
repository.fetchPosts(page = 1).fold(
val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = 1, tagFilter = tagFilter).fold(
onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() }
hasMorePages = response.meta?.pagination?.next != null
@ -64,6 +68,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 {
@ -90,7 +104,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isLoadingMore = true) }
currentPage++
repository.fetchPosts(page = currentPage).fold(
val tagFilter = _uiState.value.activeTagFilter
repository.fetchPosts(page = currentPage, tagFilter = tagFilter).fold(
onSuccess = { response ->
val newPosts = response.posts.map { it.toFeedPost() }
remotePosts = remotePosts + newPosts
@ -133,8 +148,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
_uiState.update { it.copy(posts = allPosts) }
}
@ -148,6 +170,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
tags = tags?.map { it.name } ?: emptyList(),
status = status ?: "draft",
publishedAt = published_at,
createdAt = created_at,
@ -155,7 +178,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,
@ -166,6 +195,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = linkTitle,
linkDescription = linkDescription,
linkImageUrl = linkImageUrl,
tags = tagNames,
status = status.name.lowercase(),
publishedAt = null,
createdAt = null,
@ -174,13 +204,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
queueStatus = queueStatus
)
}
}
data class FeedUiState(
val posts: List<FeedPost> = emptyList(),
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val isConnectionError: Boolean = false
val isConnectionError: Boolean = false,
val activeTagFilter: String? = null
)
fun formatRelativeTime(isoString: String?): String {

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(
@ -39,6 +42,14 @@ class PostUploadWorker(
val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription)
// 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,
@ -49,7 +60,8 @@ class PostUploadWorker(
},
feature_image = featureImage,
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)
}
}