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 3affb54..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,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 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 a76d169..080f57f 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 @@ -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 '[]'") } } 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 c9042f1..2e73e6e 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 @@ -46,7 +46,14 @@ data class GhostPost( val custom_excerpt: String? = null, val visibility: String? = "public", val authors: List? = null, - val reading_time: Int? = null + val reading_time: Int? = null, + val tags: List? = 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 = emptyList(), val status: String, val featured: Boolean = false, val publishedAt: 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 92d3703..6c0d72b 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 @@ -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 = withContext(Dispatchers.IO) { try { + // Combine status filter and tag filter + val filterParts = mutableListOf() + 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()!!) 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 2426ab4..b75b751 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 @@ -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) + } +} 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 2d2a10a..cc4a399 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,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 = 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 c480677..c5fb52c 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 @@ -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)) 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 70729bc..827bf48 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 @@ -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)) 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 470cf2e..23c0ed7 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 @@ -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 { 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? = 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 { 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,26 +440,34 @@ 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, - imageAlt = imageAlt, - linkUrl = linkUrl, - linkTitle = linkTitle, - linkDescription = linkDescription, - linkImageUrl = linkImageUrl, - status = status.name.lowercase(), - featured = featured, - 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, + imageAlt = imageAlt, + linkUrl = linkUrl, + linkTitle = linkTitle, + linkDescription = linkDescription, + linkImageUrl = linkImageUrl, + tags = tagNames, + status = status.name.lowercase(), + featured = featured, + publishedAt = null, + createdAt = null, + updatedAt = null, + 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 ) /** 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 ffbf308..a042013 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( @@ -42,6 +45,14 @@ class PostUploadWorker( featureImage, post.imageAlt ) + // 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, @@ -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) { 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) + } +}