mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-04-01 04:15:42 +00:00
feat: add hashtag parsing, highlighting, tag chips, and feed filtering by tag
This commit is contained in:
parent
74f42fd2f1
commit
5a41944a97
13 changed files with 742 additions and 37 deletions
78
app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt
Normal file
78
app/src/main/java/com/swoosh/microblog/data/HashtagParser.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,10 @@ interface GhostApiService {
|
||||||
suspend fun getPosts(
|
suspend fun getPosts(
|
||||||
@Query("limit") limit: Int = 15,
|
@Query("limit") limit: Int = 15,
|
||||||
@Query("page") page: Int = 1,
|
@Query("page") page: Int = 1,
|
||||||
@Query("include") include: String = "authors",
|
@Query("include") include: String = "authors,tags",
|
||||||
@Query("formats") formats: String = "html,plaintext,mobiledoc",
|
@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>
|
): Response<PostsResponse>
|
||||||
|
|
||||||
@POST("ghost/api/admin/posts/")
|
@POST("ghost/api/admin/posts/")
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.swoosh.microblog.data.model.LocalPost
|
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)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
|
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: AppDatabase? = null
|
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 {
|
fun getInstance(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"swoosh_database"
|
"swoosh_database"
|
||||||
).build()
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2)
|
||||||
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,14 @@ data class GhostPost(
|
||||||
val published_at: String? = null,
|
val published_at: String? = null,
|
||||||
val custom_excerpt: String? = null,
|
val custom_excerpt: String? = null,
|
||||||
val visibility: String? = "public",
|
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(
|
data class Author(
|
||||||
|
|
@ -67,6 +74,7 @@ data class LocalPost(
|
||||||
val linkDescription: String? = null,
|
val linkDescription: String? = null,
|
||||||
val linkImageUrl: String? = null,
|
val linkImageUrl: String? = null,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
|
val tags: String = "[]",
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
val queueStatus: QueueStatus = QueueStatus.NONE
|
val queueStatus: QueueStatus = QueueStatus.NONE
|
||||||
|
|
@ -99,6 +107,7 @@ data class FeedPost(
|
||||||
val linkTitle: String?,
|
val linkTitle: String?,
|
||||||
val linkDescription: String?,
|
val linkDescription: String?,
|
||||||
val linkImageUrl: String?,
|
val linkImageUrl: String?,
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
val status: String,
|
val status: String,
|
||||||
val publishedAt: String?,
|
val publishedAt: String?,
|
||||||
val createdAt: String?,
|
val createdAt: String?,
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,11 @@ class PostRepository(private val context: Context) {
|
||||||
|
|
||||||
// --- Remote operations ---
|
// --- 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) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
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) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body()!!)
|
Result.success(response.body()!!)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,24 @@ import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.filled.Image
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
@ -81,7 +89,12 @@ fun ComposerScreen(
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp)
|
.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(
|
OutlinedTextField(
|
||||||
value = state.text,
|
value = state.text,
|
||||||
onValueChange = viewModel::updateText,
|
onValueChange = viewModel::updateText,
|
||||||
|
|
@ -89,6 +102,7 @@ fun ComposerScreen(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 150.dp),
|
.heightIn(min = 150.dp),
|
||||||
placeholder = { Text("What's on your mind?") },
|
placeholder = { Text("What's on your mind?") },
|
||||||
|
visualTransformation = hashtagTransformation,
|
||||||
supportingText = {
|
supportingText = {
|
||||||
Text(
|
Text(
|
||||||
"${state.text.length} characters",
|
"${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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Attachment buttons row
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.MobiledocBuilder
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
import com.swoosh.microblog.worker.PostUploadWorker
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -41,13 +44,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
description = post.linkDescription,
|
description = post.linkDescription,
|
||||||
imageUrl = post.linkImageUrl
|
imageUrl = post.linkImageUrl
|
||||||
) else null,
|
) else null,
|
||||||
|
extractedTags = HashtagParser.parse(post.textContent),
|
||||||
isEditing = true
|
isEditing = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateText(text: String) {
|
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?) {
|
fun setImage(uri: Uri?) {
|
||||||
|
|
@ -88,6 +93,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||||
|
|
||||||
val title = state.text.take(60)
|
val title = state.text.take(60)
|
||||||
|
val extractedTags = HashtagParser.parse(state.text)
|
||||||
|
val tagsJson = Gson().toJson(extractedTags)
|
||||||
|
|
||||||
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
|
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
|
||||||
// Save locally
|
// Save locally
|
||||||
|
|
@ -103,6 +110,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
linkImageUrl = state.linkPreview?.imageUrl,
|
linkImageUrl = state.linkPreview?.imageUrl,
|
||||||
scheduledAt = state.scheduledAt,
|
scheduledAt = state.scheduledAt,
|
||||||
|
tags = tagsJson,
|
||||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
|
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
|
||||||
)
|
)
|
||||||
repository.saveLocalPost(localPost)
|
repository.saveLocalPost(localPost)
|
||||||
|
|
@ -128,6 +136,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
}
|
}
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview)
|
val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview)
|
||||||
|
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -135,7 +144,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
status = status.name.lowercase(),
|
status = status.name.lowercase(),
|
||||||
feature_image = featureImage,
|
feature_image = featureImage,
|
||||||
published_at = state.scheduledAt,
|
published_at = state.scheduledAt,
|
||||||
visibility = "public"
|
visibility = "public",
|
||||||
|
tags = ghostTags.ifEmpty { null }
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = if (editingGhostId != null) {
|
val result = if (editingGhostId != null) {
|
||||||
|
|
@ -164,6 +174,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
linkImageUrl = state.linkPreview?.imageUrl,
|
linkImageUrl = state.linkPreview?.imageUrl,
|
||||||
scheduledAt = state.scheduledAt,
|
scheduledAt = state.scheduledAt,
|
||||||
|
tags = tagsJson,
|
||||||
queueStatus = offlineQueueStatus
|
queueStatus = offlineQueueStatus
|
||||||
)
|
)
|
||||||
repository.saveLocalPost(localPost)
|
repository.saveLocalPost(localPost)
|
||||||
|
|
@ -188,6 +199,7 @@ data class ComposerUiState(
|
||||||
val linkPreview: LinkPreview? = null,
|
val linkPreview: LinkPreview? = null,
|
||||||
val isLoadingLink: Boolean = false,
|
val isLoadingLink: Boolean = false,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
|
val extractedTags: List<String> = emptyList(),
|
||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
val isSuccess: Boolean = false,
|
val isSuccess: Boolean = false,
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import com.swoosh.microblog.data.model.FeedPost
|
||||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailScreen(
|
fun DetailScreen(
|
||||||
post: FeedPost,
|
post: FeedPost,
|
||||||
|
|
@ -76,6 +76,29 @@ fun DetailScreen(
|
||||||
style = MaterialTheme.typography.bodyLarge
|
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
|
// Full image
|
||||||
if (post.imageUrl != null) {
|
if (post.imageUrl != null) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.icons.filled.WifiOff
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
|
|
@ -86,6 +88,23 @@ fun FeedScreen(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.pullRefresh(pullRefreshState)
|
.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.posts.isEmpty() && !state.isRefreshing) {
|
||||||
if (state.isConnectionError && state.error != null) {
|
if (state.isConnectionError && state.error != null) {
|
||||||
// Connection error empty state
|
// Connection error empty state
|
||||||
|
|
@ -145,13 +164,17 @@ fun FeedScreen(
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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 ->
|
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
||||||
PostCard(
|
PostCard(
|
||||||
post = post,
|
post = post,
|
||||||
onClick = { onPostClick(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
|
@Composable
|
||||||
fun PostCard(
|
fun PostCard(
|
||||||
post: FeedPost,
|
post: FeedPost,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onCancelQueue: () -> Unit
|
onCancelQueue: () -> Unit,
|
||||||
|
onTagClick: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val displayText = if (expanded || post.textContent.length <= 280) {
|
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
|
// Link preview
|
||||||
if (post.linkUrl != null && post.linkTitle != null) {
|
if (post.linkUrl != null && post.linkTitle != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.feed
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
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.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
|
|
@ -49,7 +52,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
hasMorePages = true
|
hasMorePages = true
|
||||||
|
|
||||||
repository.fetchPosts(page = 1).fold(
|
val tagFilter = _uiState.value.activeTagFilter
|
||||||
|
repository.fetchPosts(page = 1, tagFilter = tagFilter).fold(
|
||||||
onSuccess = { response ->
|
onSuccess = { response ->
|
||||||
remotePosts = response.posts.map { it.toFeedPost() }
|
remotePosts = response.posts.map { it.toFeedPost() }
|
||||||
hasMorePages = response.meta?.pagination?.next != null
|
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> {
|
private fun classifyError(e: Throwable): Pair<String, Boolean> {
|
||||||
val cause = e.cause ?: e
|
val cause = e.cause ?: e
|
||||||
return when {
|
return when {
|
||||||
|
|
@ -90,7 +104,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(isLoadingMore = true) }
|
_uiState.update { it.copy(isLoadingMore = true) }
|
||||||
currentPage++
|
currentPage++
|
||||||
|
|
||||||
repository.fetchPosts(page = currentPage).fold(
|
val tagFilter = _uiState.value.activeTagFilter
|
||||||
|
repository.fetchPosts(page = currentPage, tagFilter = tagFilter).fold(
|
||||||
onSuccess = { response ->
|
onSuccess = { response ->
|
||||||
val newPosts = response.posts.map { it.toFeedPost() }
|
val newPosts = response.posts.map { it.toFeedPost() }
|
||||||
remotePosts = remotePosts + newPosts
|
remotePosts = remotePosts + newPosts
|
||||||
|
|
@ -133,8 +148,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
|
private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
|
||||||
|
val tagFilter = _uiState.value.activeTagFilter
|
||||||
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
|
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) }
|
_uiState.update { it.copy(posts = allPosts) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +170,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
linkTitle = null,
|
linkTitle = null,
|
||||||
linkDescription = null,
|
linkDescription = null,
|
||||||
linkImageUrl = null,
|
linkImageUrl = null,
|
||||||
|
tags = tags?.map { it.name } ?: emptyList(),
|
||||||
status = status ?: "draft",
|
status = status ?: "draft",
|
||||||
publishedAt = published_at,
|
publishedAt = published_at,
|
||||||
createdAt = created_at,
|
createdAt = created_at,
|
||||||
|
|
@ -155,7 +178,13 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
isLocal = false
|
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,
|
localId = localId,
|
||||||
ghostId = ghostId,
|
ghostId = ghostId,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -166,6 +195,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
linkTitle = linkTitle,
|
linkTitle = linkTitle,
|
||||||
linkDescription = linkDescription,
|
linkDescription = linkDescription,
|
||||||
linkImageUrl = linkImageUrl,
|
linkImageUrl = linkImageUrl,
|
||||||
|
tags = tagNames,
|
||||||
status = status.name.lowercase(),
|
status = status.name.lowercase(),
|
||||||
publishedAt = null,
|
publishedAt = null,
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
|
|
@ -174,13 +204,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
queueStatus = queueStatus
|
queueStatus = queueStatus
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class FeedUiState(
|
data class FeedUiState(
|
||||||
val posts: List<FeedPost> = emptyList(),
|
val posts: List<FeedPost> = emptyList(),
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isConnectionError: Boolean = false
|
val isConnectionError: Boolean = false,
|
||||||
|
val activeTagFilter: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun formatRelativeTime(isoString: String?): String {
|
fun formatRelativeTime(isoString: String?): String {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ import android.net.Uri
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import com.swoosh.microblog.data.MobiledocBuilder
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
import com.swoosh.microblog.data.model.GhostPost
|
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.model.QueueStatus
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class PostUploadWorker(
|
class PostUploadWorker(
|
||||||
|
|
@ -39,6 +42,14 @@ class PostUploadWorker(
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription)
|
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(
|
val ghostPost = GhostPost(
|
||||||
title = post.title,
|
title = post.title,
|
||||||
mobiledoc = mobiledoc,
|
mobiledoc = mobiledoc,
|
||||||
|
|
@ -49,7 +60,8 @@ class PostUploadWorker(
|
||||||
},
|
},
|
||||||
feature_image = featureImage,
|
feature_image = featureImage,
|
||||||
published_at = post.scheduledAt,
|
published_at = post.scheduledAt,
|
||||||
visibility = "public"
|
visibility = "public",
|
||||||
|
tags = ghostTags.ifEmpty { null }
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = if (post.ghostId != null) {
|
val result = if (post.ghostId != null) {
|
||||||
|
|
|
||||||
218
app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt
Normal file
218
app/src/test/java/com/swoosh/microblog/data/HashtagParserTest.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue