mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate hashtag support (resolve conflicts)
This commit is contained in:
commit
91982a66a2
13 changed files with 733 additions and 48 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,7 +13,7 @@ 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
|
@Query("filter") filter: String? = null
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
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 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 featured INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,14 @@ data class GhostPost(
|
||||||
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 reading_time: Int? = null
|
val reading_time: Int? = null,
|
||||||
|
val tags: List<GhostTag>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GhostTag(
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String,
|
||||||
|
val slug: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Author(
|
data class Author(
|
||||||
|
|
@ -74,6 +81,7 @@ data class LocalPost(
|
||||||
val linkImageUrl: String? = null,
|
val linkImageUrl: String? = null,
|
||||||
val imageAlt: String? = null,
|
val imageAlt: 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
|
||||||
|
|
@ -109,6 +117,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 featured: Boolean = false,
|
val featured: Boolean = false,
|
||||||
val publishedAt: String?,
|
val publishedAt: String?,
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,22 @@ class PostRepository(private val context: Context) {
|
||||||
page: Int = 1,
|
page: Int = 1,
|
||||||
limit: Int = 15,
|
limit: Int = 15,
|
||||||
filter: PostFilter = PostFilter.ALL,
|
filter: PostFilter = PostFilter.ALL,
|
||||||
sortOrder: SortOrder = SortOrder.NEWEST
|
sortOrder: SortOrder = SortOrder.NEWEST,
|
||||||
|
tagFilter: String? = null
|
||||||
): Result<PostsResponse> =
|
): Result<PostsResponse> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
// Combine status filter and tag filter
|
||||||
|
val filterParts = mutableListOf<String>()
|
||||||
|
filter.ghostFilter?.let { filterParts.add(it) }
|
||||||
|
tagFilter?.let { filterParts.add("tag:$it") }
|
||||||
|
val combinedFilter = filterParts.takeIf { it.isNotEmpty() }?.joinToString("+")
|
||||||
|
|
||||||
val response = getApi().getPosts(
|
val response = getApi().getPosts(
|
||||||
limit = limit,
|
limit = limit,
|
||||||
page = page,
|
page = page,
|
||||||
order = sortOrder.ghostOrder,
|
order = sortOrder.ghostOrder,
|
||||||
filter = filter.ghostFilter
|
filter = combinedFilter
|
||||||
)
|
)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body()!!)
|
Result.success(response.body()!!)
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,22 @@ 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.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
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.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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
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 com.swoosh.microblog.data.model.PostStats
|
import com.swoosh.microblog.data.model.PostStats
|
||||||
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
|
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
|
||||||
|
|
@ -96,6 +103,12 @@ fun ComposerScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.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
|
// Edit / Preview segmented button row
|
||||||
SingleChoiceSegmentedButtonRow(
|
SingleChoiceSegmentedButtonRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -168,7 +181,7 @@ 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
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.text,
|
value = state.text,
|
||||||
onValueChange = viewModel::updateText,
|
onValueChange = viewModel::updateText,
|
||||||
|
|
@ -176,6 +189,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 = {
|
||||||
val charCount = state.text.length
|
val charCount = state.text.length
|
||||||
val statsText = PostStats.formatComposerStats(state.text)
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Attachment buttons row
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ 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.PreviewHtmlBuilder
|
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||||
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.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -48,13 +51,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
imageUrl = post.linkImageUrl
|
imageUrl = post.linkImageUrl
|
||||||
) else null,
|
) else null,
|
||||||
featured = post.featured,
|
featured = post.featured,
|
||||||
|
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) }
|
||||||
if (_uiState.value.isPreviewMode) {
|
if (_uiState.value.isPreviewMode) {
|
||||||
debouncedPreviewUpdate()
|
debouncedPreviewUpdate()
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +166,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)
|
||||||
|
|
||||||
val altText = state.imageAlt.ifBlank { null }
|
val altText = state.imageAlt.ifBlank { null }
|
||||||
|
|
||||||
|
|
@ -180,6 +187,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)
|
||||||
|
|
@ -212,6 +220,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
featureImage,
|
featureImage,
|
||||||
altText
|
altText
|
||||||
)
|
)
|
||||||
|
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -221,7 +230,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
feature_image = featureImage,
|
feature_image = featureImage,
|
||||||
feature_image_alt = altText,
|
feature_image_alt = altText,
|
||||||
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) {
|
||||||
|
|
@ -252,6 +262,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)
|
||||||
|
|
@ -283,6 +294,7 @@ data class ComposerUiState(
|
||||||
val isLoadingLink: Boolean = false,
|
val isLoadingLink: Boolean = false,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
val featured: Boolean = false,
|
val featured: Boolean = false,
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ import com.swoosh.microblog.ui.feed.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailScreen(
|
fun DetailScreen(
|
||||||
post: FeedPost,
|
post: FeedPost,
|
||||||
|
|
@ -202,6 +202,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))
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.automirrored.filled.ArrowBack
|
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.filled.*
|
||||||
import androidx.compose.material.icons.outlined.FilterList
|
import androidx.compose.material.icons.outlined.FilterList
|
||||||
import androidx.compose.material.icons.outlined.PushPin
|
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
|
// Show recent searches when search is active but query is empty
|
||||||
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
|
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
|
||||||
RecentSearchesList(
|
RecentSearchesList(
|
||||||
|
|
@ -415,6 +418,7 @@ fun FeedScreen(
|
||||||
onEdit = { onEditPost(post) },
|
onEdit = { onEditPost(post) },
|
||||||
onDelete = { postPendingDelete = post },
|
onDelete = { postPendingDelete = post },
|
||||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||||
|
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||||
snackbarHostState = snackbarHostState
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +459,7 @@ fun FeedScreen(
|
||||||
onEdit = { onEditPost(post) },
|
onEdit = { onEditPost(post) },
|
||||||
onDelete = { postPendingDelete = post },
|
onDelete = { postPendingDelete = post },
|
||||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||||
|
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||||
snackbarHostState = snackbarHostState
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -663,6 +668,7 @@ fun SwipeablePostCard(
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onTogglePin: () -> Unit = {},
|
onTogglePin: () -> Unit = {},
|
||||||
|
onTagClick: (String) -> Unit = {},
|
||||||
snackbarHostState: SnackbarHostState? = null
|
snackbarHostState: SnackbarHostState? = null
|
||||||
) {
|
) {
|
||||||
val dismissState = rememberSwipeToDismissBoxState(
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
|
|
@ -713,6 +719,7 @@ fun SwipeablePostCard(
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
onDelete = onDelete,
|
onDelete = onDelete,
|
||||||
onTogglePin = onTogglePin,
|
onTogglePin = onTogglePin,
|
||||||
|
onTagClick = onTagClick,
|
||||||
snackbarHostState = snackbarHostState
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -790,7 +797,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchTopBar(
|
fun SearchTopBar(
|
||||||
query: String,
|
query: String,
|
||||||
|
|
@ -917,6 +924,7 @@ fun PostCardContent(
|
||||||
onEdit: () -> Unit = {},
|
onEdit: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onTogglePin: () -> Unit = {},
|
onTogglePin: () -> Unit = {},
|
||||||
|
onTagClick: (String) -> Unit = {},
|
||||||
snackbarHostState: SnackbarHostState? = null,
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
highlightQuery: String? = 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
|
// 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))
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.CredentialsManager
|
||||||
import com.swoosh.microblog.data.FeedPreferences
|
import com.swoosh.microblog.data.FeedPreferences
|
||||||
|
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.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
|
@ -205,8 +208,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
val filter = _activeFilter.value
|
val filter = _activeFilter.value
|
||||||
val sort = _sortOrder.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 ->
|
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
|
||||||
|
|
@ -221,6 +225,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun filterByTag(tag: String) {
|
||||||
|
_uiState.update { it.copy(activeTagFilter = tag) }
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearTagFilter() {
|
||||||
|
_uiState.update { it.copy(activeTagFilter = null) }
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
private fun classifyError(e: Throwable): Pair<String, Boolean> {
|
private fun classifyError(e: Throwable): Pair<String, Boolean> {
|
||||||
val cause = e.cause ?: e
|
val cause = e.cause ?: e
|
||||||
return when {
|
return when {
|
||||||
|
|
@ -249,8 +263,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
val filter = _activeFilter.value
|
val filter = _activeFilter.value
|
||||||
val sort = _sortOrder.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 ->
|
onSuccess = { response ->
|
||||||
val newPosts = response.posts.map { it.toFeedPost() }
|
val newPosts = response.posts.map { it.toFeedPost() }
|
||||||
remotePosts = remotePosts + newPosts
|
remotePosts = remotePosts + newPosts
|
||||||
|
|
@ -385,8 +400,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
|
||||||
// Sort: featured/pinned posts first, then chronological
|
// Sort: featured/pinned posts first, then chronological
|
||||||
val sorted = allPosts.sortedWith(
|
val sorted = allPosts.sortedWith(
|
||||||
compareByDescending<FeedPost> { it.featured }
|
compareByDescending<FeedPost> { it.featured }
|
||||||
|
|
@ -409,6 +431,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",
|
||||||
featured = featured ?: false,
|
featured = featured ?: false,
|
||||||
publishedAt = published_at,
|
publishedAt = published_at,
|
||||||
|
|
@ -417,26 +440,34 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
isLocal = false
|
isLocal = false
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun LocalPost.toFeedPost(): FeedPost = FeedPost(
|
private fun LocalPost.toFeedPost(): FeedPost {
|
||||||
localId = localId,
|
val tagNames: List<String> = try {
|
||||||
ghostId = ghostId,
|
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||||
title = title,
|
} catch (e: Exception) {
|
||||||
textContent = content,
|
emptyList()
|
||||||
htmlContent = htmlContent,
|
}
|
||||||
imageUrl = uploadedImageUrl ?: imageUri,
|
return FeedPost(
|
||||||
imageAlt = imageAlt,
|
localId = localId,
|
||||||
linkUrl = linkUrl,
|
ghostId = ghostId,
|
||||||
linkTitle = linkTitle,
|
title = title,
|
||||||
linkDescription = linkDescription,
|
textContent = content,
|
||||||
linkImageUrl = linkImageUrl,
|
htmlContent = htmlContent,
|
||||||
status = status.name.lowercase(),
|
imageUrl = uploadedImageUrl ?: imageUri,
|
||||||
featured = featured,
|
imageAlt = imageAlt,
|
||||||
publishedAt = null,
|
linkUrl = linkUrl,
|
||||||
createdAt = null,
|
linkTitle = linkTitle,
|
||||||
updatedAt = null,
|
linkDescription = linkDescription,
|
||||||
isLocal = true,
|
linkImageUrl = linkImageUrl,
|
||||||
queueStatus = queueStatus
|
tags = tagNames,
|
||||||
)
|
status = status.name.lowercase(),
|
||||||
|
featured = featured,
|
||||||
|
publishedAt = null,
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null,
|
||||||
|
isLocal = true,
|
||||||
|
queueStatus = queueStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
|
@ -468,7 +499,8 @@ data class FeedUiState(
|
||||||
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 snackbarMessage: String? = null
|
val snackbarMessage: String? = null,
|
||||||
|
val activeTagFilter: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -42,6 +45,14 @@ class PostUploadWorker(
|
||||||
featureImage, post.imageAlt
|
featureImage, post.imageAlt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Parse tags from JSON stored in LocalPost
|
||||||
|
val tagNames: List<String> = try {
|
||||||
|
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val ghostTags = tagNames.map { GhostTag(name = it) }
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = post.title,
|
title = post.title,
|
||||||
mobiledoc = mobiledoc,
|
mobiledoc = mobiledoc,
|
||||||
|
|
@ -54,7 +65,8 @@ class PostUploadWorker(
|
||||||
feature_image = featureImage,
|
feature_image = featureImage,
|
||||||
feature_image_alt = post.imageAlt,
|
feature_image_alt = post.imageAlt,
|
||||||
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