From 5a41944a97090f3441916376a1df8c13979503a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:37:11 +0100 Subject: [PATCH] feat: add hashtag parsing, highlighting, tag chips, and feed filtering by tag --- .../swoosh/microblog/data/HashtagParser.kt | 78 +++++++ .../microblog/data/api/GhostApiService.kt | 5 +- .../swoosh/microblog/data/db/AppDatabase.kt | 14 +- .../microblog/data/model/GhostModels.kt | 11 +- .../data/repository/PostRepository.kt | 5 +- .../microblog/ui/composer/ComposerScreen.kt | 79 ++++++- .../ui/composer/ComposerViewModel.kt | 16 +- .../microblog/ui/detail/DetailScreen.kt | 25 +- .../swoosh/microblog/ui/feed/FeedScreen.kt | 54 ++++- .../swoosh/microblog/ui/feed/FeedViewModel.kt | 76 ++++-- .../microblog/worker/PostUploadWorker.kt | 14 +- .../microblog/data/HashtagParserTest.kt | 218 ++++++++++++++++++ .../microblog/data/model/GhostTagTest.kt | 184 +++++++++++++++ 13 files changed, 742 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/GhostTagTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt b/app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt new file mode 100644 index 0000000..72e28a1 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt @@ -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 { + 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() + val result = mutableListOf() + + 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 { + 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() + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index b51ebe8..6469db0 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -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 @POST("ghost/api/admin/posts/") diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt index 3b3f23f..26ccfab 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..3e595fe 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -41,7 +41,14 @@ data class GhostPost( val published_at: String? = null, val custom_excerpt: String? = null, val visibility: String? = "public", - val authors: List? = null + val authors: List? = null, + val tags: List? = 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 = emptyList(), val status: String, val publishedAt: String?, val createdAt: String?, diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 8a567ab..46ddfc9 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -30,10 +30,11 @@ class PostRepository(private val context: Context) { // --- Remote operations --- - suspend fun fetchPosts(page: Int = 1, limit: Int = 15): Result = + suspend fun fetchPosts(page: Int = 1, limit: Int = 15, tagFilter: String? = null): Result = 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 { diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index b26f002..f9bca90 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 86e9683..c54d70e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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 = emptyList(), val isSubmitting: Boolean = false, val isSuccess: Boolean = false, val isEditing: Boolean = false, diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index bc6d465..5911a39 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -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)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..a81f2e9 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..2f59ef1 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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 { 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? = 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,24 +178,32 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { isLocal = false ) - private fun LocalPost.toFeedPost(): FeedPost = FeedPost( - localId = localId, - ghostId = ghostId, - title = title, - textContent = content, - htmlContent = htmlContent, - imageUrl = uploadedImageUrl ?: imageUri, - linkUrl = linkUrl, - linkTitle = linkTitle, - linkDescription = linkDescription, - linkImageUrl = linkImageUrl, - status = status.name.lowercase(), - publishedAt = null, - createdAt = null, - updatedAt = null, - isLocal = true, - queueStatus = queueStatus - ) + private fun LocalPost.toFeedPost(): FeedPost { + val tagNames: List = try { + Gson().fromJson(tags, object : TypeToken>() {}.type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + return FeedPost( + localId = localId, + ghostId = ghostId, + title = title, + textContent = content, + htmlContent = htmlContent, + imageUrl = uploadedImageUrl ?: imageUri, + linkUrl = linkUrl, + linkTitle = linkTitle, + linkDescription = linkDescription, + linkImageUrl = linkImageUrl, + tags = tagNames, + status = status.name.lowercase(), + publishedAt = null, + createdAt = null, + updatedAt = null, + isLocal = true, + queueStatus = queueStatus + ) + } } data class FeedUiState( @@ -180,7 +211,8 @@ data class FeedUiState( 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 { diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 17c9357..a093921 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -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 = try { + Gson().fromJson(post.tags, object : TypeToken>() {}.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) { diff --git a/app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt b/app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt new file mode 100644 index 0000000..668b9a7 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostTagTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostTagTest.kt new file mode 100644 index 0000000..ca389d1 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostTagTest.kt @@ -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>() {}.type + val tags: List = 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>() {}.type + val restored: List = gson.fromJson(json, type) + assertEquals(tagNames, restored) + } + + @Test + fun `Empty tag list stored as JSON round-trips correctly`() { + val tagNames = emptyList() + val json = gson.toJson(tagNames) + assertEquals("[]", json) + val type = object : TypeToken>() {}.type + val restored: List = 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) + } +}