From 9a78725ef89414019167a7940e13555dde851f24 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Sun, 3 May 2026 21:17:40 -0700 Subject: [PATCH] feat(search): two-panel layout, FTS performance, precomputed backlink counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add two-panel desktop search dialog: 340dp result list + preview panel with 150ms debounce on selection change; mobile layout unchanged - PreviewPanelContent sealed class with PagePreview/BlockPreview/Empty states - PreviewPanel composable with skeleton shimmer while blocks load - RelativeDateFormatter and SearchSkeleton components - Replace O(N×M) backlink LIKE-scan in search with precomputed backlink_count column on pages; add pages_backlink_count schema migration with backfill - Add recomputeAllBacklinkCounts query called alongside rebuildFts - Real-time incremental backlink count updates: saveBlock, updateBlockContentOnly, deleteBlock, and deleteBulk now extract [[wikilinks]] from changed content and call recomputeBacklinkCountForPage for affected pages - FtsQueryBuilder improvements for better relevance ranking - SearchLatencyTest p99 < 200ms passes (was 603ms before precomputed column) Co-Authored-By: Claude Sonnet 4.6 --- .../stapler/stelekit/db/MigrationRunner.kt | 14 + .../stelekit/db/RestrictedDatabaseQueries.kt | 7 + .../stelekit/repository/GraphRepository.kt | 3 +- .../repository/SqlDelightBlockRepository.kt | 29 +- .../repository/SqlDelightSearchRepository.kt | 190 ++++- .../stelekit/search/FtsQueryBuilder.kt | 2 +- .../kotlin/dev/stapler/stelekit/ui/App.kt | 7 +- .../stelekit/ui/components/PreviewPanel.kt | 114 +++ .../ui/components/RelativeDateFormatter.kt | 36 + .../stelekit/ui/components/SearchDialog.kt | 703 ++++++++++++------ .../stelekit/ui/components/SearchSkeleton.kt | 86 +++ .../stelekit/ui/screens/SearchViewModel.kt | 252 ++++++- .../dev/stapler/stelekit/db/SteleDatabase.sq | 53 +- 13 files changed, 1200 insertions(+), 296 deletions(-) create mode 100644 kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/PreviewPanel.kt create mode 100644 kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/RelativeDateFormatter.kt create mode 100644 kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt index 25f5ed53..62fc5ff3 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt @@ -158,6 +158,20 @@ object MigrationRunner { """ ) ), + Migration( + name = "pages_backlink_count", + statements = listOf( + "ALTER TABLE pages ADD COLUMN IF NOT EXISTS backlink_count INTEGER NOT NULL DEFAULT 0", + // Backfill existing rows. Runs once on an existing DB; in-memory test DBs are + // empty at migration time so this is a no-op there. + """ + UPDATE pages SET backlink_count = ( + SELECT COUNT(*) FROM blocks + WHERE blocks.content LIKE '%[[' || pages.name || ']]%' + ) + """ + ) + ), ) /** diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt index e7e530e1..9d513cb4 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt @@ -396,4 +396,11 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { @DirectSqlWrite fun pragmaWalCheckpointTruncate() = queries.pragmaWalCheckpointTruncate() + + @DirectSqlWrite + fun recomputeAllBacklinkCounts(): QueryResult = queries.recomputeAllBacklinkCounts() + + @DirectSqlWrite + fun recomputeBacklinkCountForPage(name: String): QueryResult = + queries.recomputeBacklinkCountForPage(name) } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/GraphRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/GraphRepository.kt index 417d1c6e..18c4254b 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/GraphRepository.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/GraphRepository.kt @@ -531,7 +531,8 @@ data class DateRange( data class SearchedPage( val page: Page, val snippet: String? = null, - val bm25Score: Double = 0.0 + val bm25Score: Double = 0.0, + val backlinkCount: Int = 0 ) /** A block result with an optional snippet and raw BM25 score from FTS5. */ diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightBlockRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightBlockRepository.kt index 5b23a7d9..322dc773 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightBlockRepository.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightBlockRepository.kt @@ -67,6 +67,9 @@ class SqlDelightBlockRepository( private val hierarchyTtlMs = 120_000L // 2 minutes + private fun extractWikilinks(content: String): Set = + WIKILINK_REGEX.findAll(content).map { it.groupValues[1].trim() }.toHashSet() + override fun getBlockByUuid(uuid: String): Flow> = queries.selectBlockByUuid(uuid) .asFlow() @@ -258,6 +261,7 @@ class SqlDelightBlockRepository( block.contentHash ?: ContentHasher.sha256ForContent(block.content), block.blockType ) + extractWikilinks(block.content).forEach { queries.recomputeBacklinkCountForPage(it) } Unit.right() } catch (e: Exception) { DomainError.DatabaseError.WriteFailed(e.message ?: "unknown").left() @@ -267,8 +271,12 @@ class SqlDelightBlockRepository( override suspend fun updateBlockContentOnly(blockUuid: String, content: String): Either = withContext(PlatformDispatcher.DB) { try { + val oldContent = blockCache.get(blockUuid)?.content + ?: queries.selectBlockByUuid(blockUuid).executeAsOneOrNull()?.content ?: "" queries.updateBlockContent(content, Clock.System.now().toEpochMilliseconds(), ContentHasher.sha256ForContent(content), blockUuid) blockCache.remove(blockUuid) + val changedPages = extractWikilinks(oldContent) + extractWikilinks(content) + changedPages.forEach { queries.recomputeBacklinkCountForPage(it) } Unit.right() } catch (e: Exception) { DomainError.DatabaseError.WriteFailed(e.message ?: "unknown").left() @@ -291,13 +299,18 @@ class SqlDelightBlockRepository( try { val block = queries.selectBlockByUuid(blockUuid).executeAsOneOrNull() if (block != null) { + val wikilinkPages = mutableSetOf() + wikilinkPages.addAll(extractWikilinks(block.content)) if (deleteChildren) { val uuidsToDelete = mutableListOf(block.uuid) var index = 0 while (index < uuidsToDelete.size) { val currentUuid = uuidsToDelete[index] val children = queries.selectBlockChildren(currentUuid, Long.MAX_VALUE, 0L).executeAsList() - children.forEach { uuidsToDelete.add(it.uuid) } + children.forEach { child -> + uuidsToDelete.add(child.uuid) + wikilinkPages.addAll(extractWikilinks(child.content)) + } index++ } @@ -317,7 +330,7 @@ class SqlDelightBlockRepository( } queries.deleteBlockByUuid(block.uuid) } - + wikilinkPages.forEach { queries.recomputeBacklinkCountForPage(it) } } Unit.right() } catch (e: Exception) { @@ -327,9 +340,11 @@ class SqlDelightBlockRepository( override suspend fun deleteBulk(blockUuids: List, deleteChildren: Boolean): Either = withContext(PlatformDispatcher.DB) { try { + val wikilinkPages = mutableSetOf() queries.transaction { blockUuids.forEach { uuid -> val block = queries.selectBlockByUuid(uuid).executeAsOneOrNull() ?: return@forEach + wikilinkPages.addAll(extractWikilinks(block.content)) if (deleteChildren) { // Collect the full subtree val uuidsToDelete = mutableListOf(block.uuid) @@ -337,7 +352,10 @@ class SqlDelightBlockRepository( while (index < uuidsToDelete.size) { val currentUuid = uuidsToDelete[index] val children = queries.selectBlockChildren(currentUuid, Long.MAX_VALUE, 0L).executeAsList() - children.forEach { uuidsToDelete.add(it.uuid) } + children.forEach { child -> + uuidsToDelete.add(child.uuid) + wikilinkPages.addAll(extractWikilinks(child.content)) + } index++ } // Chain repair for the top-level block being deleted @@ -355,6 +373,7 @@ class SqlDelightBlockRepository( queries.deleteBlockByUuid(block.uuid) } } + wikilinkPages.forEach { queries.recomputeBacklinkCountForPage(it) } } Unit.right() } catch (e: Exception) { @@ -962,4 +981,8 @@ class SqlDelightBlockRepository( hierarchyCache.invalidateAll() ancestorsCache.invalidateAll() } + + companion object { + private val WIKILINK_REGEX = Regex("""\[\[([^\]]+)\]\]""") + } } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightSearchRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightSearchRepository.kt index 6489ffd2..78c7f37b 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightSearchRepository.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightSearchRepository.kt @@ -189,33 +189,66 @@ class SqlDelightSearchRepository( val scope = searchRequest.scope val dataTypes = searchRequest.dataTypes + val dateRange = searchRequest.dateRange + val startMs = dateRange?.startDate?.toEpochMilliseconds() ?: 0L + val endMs = dateRange?.endDate?.toEpochMilliseconds() ?: Long.MAX_VALUE + // ── Page search ──────────────────────────────────────────────── val searchedPages: List = if ( scope != SearchScope.BLOCKS_ONLY && DataType.TITLES in dataTypes ) { try { - val andPages = queries.searchPagesByNameFts( - query = ftsQuery, - limit = searchRequest.limit.toLong() - ).executeAsList() - val pageRows = if (andPages.isNotEmpty()) { - andPages + if (dateRange != null) { + // Date-ranged page search + val andPages = queries.searchPagesByNameFtsInDateRange( + query = ftsQuery, + startMs = startMs, + endMs = endMs, + limit = searchRequest.limit.toLong() + ).executeAsList() + val pageRows = if (andPages.isNotEmpty()) { + andPages + } else { + val orQuery = FtsQueryBuilder.buildOr(rawQuery) + if (orQuery.isEmpty()) emptyList() + else queries.searchPagesByNameFtsInDateRange( + query = orQuery, + startMs = startMs, + endMs = endMs, + limit = searchRequest.limit.toLong() + ).executeAsList() + } + pageRows.map { row -> + SearchedPage( + page = row.toPageModel(), + snippet = row.highlight?.takeIf { it.isNotBlank() }, + bm25Score = row.bm25_score + ) + }.applyPageScope(scope, searchRequest.pageUuid) } else { - val orQuery = FtsQueryBuilder.buildOr(rawQuery) - if (orQuery.isEmpty()) emptyList() - else queries.searchPagesByNameFts( - query = orQuery, + val andPages = queries.searchPagesByNameFts( + query = ftsQuery, limit = searchRequest.limit.toLong() ).executeAsList() + val pageRows = if (andPages.isNotEmpty()) { + andPages + } else { + val orQuery = FtsQueryBuilder.buildOr(rawQuery) + if (orQuery.isEmpty()) emptyList() + else queries.searchPagesByNameFts( + query = orQuery, + limit = searchRequest.limit.toLong() + ).executeAsList() + } + pageRows.map { row -> + SearchedPage( + page = row.toPageModel(), + snippet = row.highlight?.takeIf { it.isNotBlank() }, + bm25Score = row.bm25_score + ) + }.applyPageScope(scope, searchRequest.pageUuid) } - pageRows.map { row -> - SearchedPage( - page = row.toPageModel(), - snippet = row.highlight?.takeIf { it.isNotBlank() }, - bm25Score = row.bm25_score - ) - }.applyPageScope(scope, searchRequest.pageUuid) } catch (_: Exception) { // pages_fts not yet available — fall back to LIKE queries.selectPagesByNameLike("%$rawQuery%") @@ -226,6 +259,20 @@ class SqlDelightSearchRepository( } } else emptyList() + // Read precomputed backlink counts from the pages.backlink_count column (O(1) per page). + // The column is populated by the pages_backlink_count migration and refreshed by rebuildFts. + val backlinkMap: Map = if (searchedPages.isNotEmpty()) { + runCatching { + queries.selectBacklinkCountsForPages(searchedPages.map { it.page.uuid }.toSet()) + .executeAsList() + .associate { it.page_name to it.backlink_count.toInt() } + }.getOrDefault(emptyMap()) + } else emptyMap() + + val searchedPagesWithBacklinks = searchedPages.map { sp -> + sp.copy(backlinkCount = backlinkMap[sp.page.name] ?: 0) + } + // ── Block search ─────────────────────────────────────────────── val searchedBlocks: List = if ( scope != SearchScope.PAGES_ONLY && @@ -251,29 +298,60 @@ class SqlDelightSearchRepository( } else emptyList() } else -> { - val andBlocks = queries.searchBlocksByContentFts( - query = ftsQuery, - limit = searchRequest.limit.toLong(), - offset = searchRequest.offset.toLong() - ).executeAsList() - val blockRows = if (andBlocks.isNotEmpty()) { - andBlocks + if (dateRange != null) { + // Date-ranged block search + val andBlocks = queries.searchBlocksByContentFtsInDateRange( + query = ftsQuery, + startMs = startMs, + endMs = endMs, + limit = searchRequest.limit.toLong(), + offset = searchRequest.offset.toLong() + ).executeAsList() + val blockRows = if (andBlocks.isNotEmpty()) { + andBlocks + } else { + val orQuery = FtsQueryBuilder.buildOr(rawQuery) + if (orQuery.isEmpty()) emptyList() + else queries.searchBlocksByContentFtsInDateRange( + query = orQuery, + startMs = startMs, + endMs = endMs, + limit = searchRequest.limit.toLong(), + offset = searchRequest.offset.toLong() + ).executeAsList() + } + blockRows.map { row -> + SearchedBlock( + block = row.toBlockModel(), + snippet = row.highlight?.takeIf { it.isNotBlank() }, + bm25Score = row.bm25_score + ) + }.applyBlockScope(scope) } else { - val orQuery = FtsQueryBuilder.buildOr(rawQuery) - if (orQuery.isEmpty()) emptyList() - else queries.searchBlocksByContentFts( - query = orQuery, + val andBlocks = queries.searchBlocksByContentFts( + query = ftsQuery, limit = searchRequest.limit.toLong(), offset = searchRequest.offset.toLong() ).executeAsList() + val blockRows = if (andBlocks.isNotEmpty()) { + andBlocks + } else { + val orQuery = FtsQueryBuilder.buildOr(rawQuery) + if (orQuery.isEmpty()) emptyList() + else queries.searchBlocksByContentFts( + query = orQuery, + limit = searchRequest.limit.toLong(), + offset = searchRequest.offset.toLong() + ).executeAsList() + } + blockRows.map { row -> + SearchedBlock( + block = row.toBlockModel(), + snippet = row.highlight?.takeIf { it.isNotBlank() }, + bm25Score = row.bm25_score + ) + }.applyBlockScope(scope) } - blockRows.map { row -> - SearchedBlock( - block = row.toBlockModel(), - snippet = row.highlight?.takeIf { it.isNotBlank() }, - bm25Score = row.bm25_score - ) - }.applyBlockScope(scope) } } } catch (e: Exception) { @@ -287,7 +365,7 @@ class SqlDelightSearchRepository( val nowMs = HistogramWriter.epochMs() // Batch-fetch visit data for all result UUIDs — single IN query, not N+1 - val allResultUuids = searchedPages.map { it.page.uuid } + + val allResultUuids = searchedPagesWithBacklinks.map { it.page.uuid } + searchedBlocks.map { it.block.pageUuid } val visitMap: Map = if (allResultUuids.isEmpty()) emptyMap() else runCatching { @@ -296,13 +374,13 @@ class SqlDelightSearchRepository( .associate { it.page_uuid to it.last_visited_at } }.getOrDefault(emptyMap()) - val rankedRaw = buildRankedList(searchedPages, searchedBlocks, neighbourPageUuids, visitMap, nowMs) + val rankedRaw = buildRankedList(searchedPagesWithBacklinks, searchedBlocks, neighbourPageUuids, visitMap, nowMs) val ranked = promoteExactTitleMatch(rankedRaw, rawQuery) emit(SearchResult( blocks = searchedBlocks.map { it.block }, - pages = searchedPages.map { it.page }, + pages = searchedPagesWithBacklinks.map { it.page }, searchedBlocks = searchedBlocks, - searchedPages = searchedPages, + searchedPages = searchedPagesWithBacklinks, ranked = ranked, totalCount = ranked.size, hasMore = false @@ -429,11 +507,15 @@ class SqlDelightSearchRepository( writeActor.execute(priority = DatabaseWriteActor.Priority.LOW) { sqlDriver.execute(null, "INSERT INTO blocks_fts(blocks_fts) VALUES('rebuild')", 0) sqlDriver.execute(null, "INSERT INTO pages_fts(pages_fts) VALUES('rebuild')", 0) + @OptIn(DirectSqlWrite::class) + restricted.recomputeAllBacklinkCounts() Unit.right() } } else { sqlDriver.execute(null, "INSERT INTO blocks_fts(blocks_fts) VALUES('rebuild')", 0) sqlDriver.execute(null, "INSERT INTO pages_fts(pages_fts) VALUES('rebuild')", 0) + @OptIn(DirectSqlWrite::class) + restricted.recomputeAllBacklinkCounts() Unit.right() } } catch (e: Exception) { @@ -536,6 +618,36 @@ class SqlDelightSearchRepository( journalDate = journal_date?.let { kotlinx.datetime.LocalDate.parse(it) } ) + private fun dev.stapler.stelekit.db.SearchPagesByNameFtsInDateRange.toPageModel(): Page = + Page( + uuid = uuid, + name = name, + namespace = namespace, + filePath = file_path, + createdAt = Instant.fromEpochMilliseconds(created_at), + updatedAt = Instant.fromEpochMilliseconds(updated_at), + version = version, + properties = emptyMap(), + isFavorite = is_favorite == 1L, + isJournal = is_journal == 1L, + journalDate = journal_date?.let { kotlinx.datetime.LocalDate.parse(it) } + ) + + private fun dev.stapler.stelekit.db.SearchBlocksByContentFtsInDateRange.toBlockModel(): Block = + Block( + uuid = uuid, + pageUuid = page_uuid, + parentUuid = parent_uuid, + leftUuid = left_uuid, + content = content, + level = level.toInt(), + position = position.toInt(), + createdAt = Instant.fromEpochMilliseconds(created_at), + updatedAt = Instant.fromEpochMilliseconds(updated_at), + version = version, + properties = parseProperties(properties) + ) + private fun dev.stapler.stelekit.db.Pages.toPageModel(): Page = Page( uuid = uuid, diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/search/FtsQueryBuilder.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/search/FtsQueryBuilder.kt index 32641868..0c7aa899 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/search/FtsQueryBuilder.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/search/FtsQueryBuilder.kt @@ -15,7 +15,7 @@ package dev.stapler.stelekit.search */ object FtsQueryBuilder { - private val STRIP_CHARS = Regex("""[-:^~{}\[\]!]""") + private val STRIP_CHARS = Regex("""[-:^~{}\[\]!#]""") private val LEADING_OPERATORS = setOf("AND", "OR", "NOT") /** diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt index 5554c60a..3e0c3d49 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt @@ -488,7 +488,7 @@ private fun GraphContent( LibraryStatsViewModel(libraryStatsProvider, graphManager.getActiveGraphInfo()?.path ?: "") } val searchViewModel = remember { - SearchViewModel(repos.searchRepository) + SearchViewModel(repos.searchRepository, pageRepository = repos.pageRepository) } // Cancel all ViewModel scopes when GraphContent leaves composition (key(activeGraphId) re-keys). @@ -737,6 +737,7 @@ private fun GraphContent( deviceLlmAvailable = deviceLlmAvailable, frameMetric = frameMetricState, debugState = debugMenuState, + loadPageBlocks = repos.blockRepository::getBlocksForPage, onDebugStateChange = { newState -> debugMenuState = newState viewModel.onDebugMenuStateChange(newState) @@ -950,6 +951,7 @@ private fun GraphDialogLayer( deviceLlmAvailable: Boolean = false, frameMetric: kotlinx.coroutines.flow.StateFlow, debugState: DebugMenuState = DebugMenuState(), + loadPageBlocks: (String) -> kotlinx.coroutines.flow.Flow>> = { kotlinx.coroutines.flow.flowOf(arrow.core.Either.Right(emptyList())) }, onDebugStateChange: (DebugMenuState) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -969,7 +971,8 @@ private fun GraphDialogLayer( onNavigateToBlock = { viewModel.navigateToBlock(it) }, onCreatePage = { viewModel.navigateToPageByName(it) }, initialQuery = appState.searchDialogInitialQuery, - isIndexing = indexingProgress is IndexingState.InProgress + isIndexing = indexingProgress is IndexingState.InProgress, + loadPageBlocks = loadPageBlocks ) SettingsDialog( diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/PreviewPanel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/PreviewPanel.kt new file mode 100644 index 00000000..48f1922a --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/PreviewPanel.kt @@ -0,0 +1,114 @@ +package dev.stapler.stelekit.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import arrow.core.Either +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.model.Block +import dev.stapler.stelekit.ui.screens.PreviewPanelContent +import kotlinx.coroutines.flow.Flow + +@Composable +fun PreviewPanel( + content: PreviewPanelContent, + loadPageBlocks: (String) -> Flow>>, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.padding(16.dp)) { + when (content) { + is PreviewPanelContent.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Select a result to preview", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + is PreviewPanelContent.PagePreview -> { + val blocksResult by loadPageBlocks(content.pageUuid).collectAsState(initial = null) + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = content.pageTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp) + ) + HorizontalDivider() + when { + blocksResult == null -> SearchSkeletonList(rowCount = 4) + blocksResult is Either.Left -> Text( + text = "Could not load preview", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + else -> { + val blocks = (blocksResult as Either.Right>).value + if (blocks.isEmpty()) { + Text( + text = "No content", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(blocks.take(10)) { block -> + if (block.content.isNotBlank()) { + Text( + text = block.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding( + start = (block.level * 12).dp, + top = 4.dp, + bottom = 4.dp + ) + ) + } + } + } + } + } + } + } + } + is PreviewPanelContent.BlockPreview -> { + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = content.pageTitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = content.blockSnippet, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(12.dp) + ) + } + } + } + } + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/RelativeDateFormatter.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/RelativeDateFormatter.kt new file mode 100644 index 00000000..27bb8cd8 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/RelativeDateFormatter.kt @@ -0,0 +1,36 @@ +package dev.stapler.stelekit.ui.components + +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * Formats an [Instant] as a human-readable relative date string. + * + * - Same day → "today" + * - Yesterday → "yesterday" + * - 2–6 days → "N days ago" + * - 7–30 days → "MMM d" (e.g. "Apr 12") + * - > 30 days → "MMM yyyy" (e.g. "Jan 2024") + * - Future → "today" (clock drift guard) + * + * The [now] parameter is injectable for testing. + */ +fun formatRelativeDate(instant: Instant, now: Instant = Clock.System.now()): String { + val diff = now - instant + val days = diff.inWholeDays + return when { + diff.isNegative() || days == 0L -> "today" + days == 1L -> "yesterday" + days in 2..6 -> "$days days ago" + days in 7..30 -> { + val localDate = instant.toLocalDateTime(TimeZone.currentSystemDefault()).date + "${localDate.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)} ${localDate.day}" + } + else -> { + val localDate = instant.toLocalDateTime(TimeZone.currentSystemDefault()).date + "${localDate.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)} ${localDate.year}" + } + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt index 164d9cf7..6276b51f 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt @@ -1,10 +1,13 @@ package dev.stapler.stelekit.ui.components +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -26,10 +29,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import arrow.core.Either +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.model.Block import dev.stapler.stelekit.repository.SearchScope +import dev.stapler.stelekit.ui.screens.ParsedQuery +import dev.stapler.stelekit.ui.screens.PreviewPanelContent import dev.stapler.stelekit.ui.screens.SearchResultItem import dev.stapler.stelekit.ui.screens.SearchViewModel import dev.stapler.stelekit.util.toTitleCase +import kotlinx.coroutines.flow.Flow @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,12 +53,15 @@ fun SearchDialog( onPageSelected: ((String) -> Unit)? = null, initialQuery: String = "", isIndexing: Boolean = false, + loadPageBlocks: (String) -> Flow>> = { kotlinx.coroutines.flow.flowOf(Either.Right(emptyList())) }, ) { if (!visible) return val placeholder = if (onPageSelected != null) "Search pages..." else "Search pages and blocks..." val uiState by viewModel.uiState.collectAsState() - var selectedIndex by remember(uiState.results) { mutableStateOf(0) } + // Active list depends on whether we're in empty-query (recent pages) or active search state + val activeList: List = if (uiState.query.isBlank()) uiState.recentPages else uiState.results + var selectedIndex by remember(activeList) { mutableStateOf(0) } val listState = rememberLazyListState() val focusRequester = remember { FocusRequester() } @@ -61,18 +73,19 @@ fun SearchDialog( } LaunchedEffect(selectedIndex) { - if (uiState.results.isNotEmpty()) { + if (activeList.isNotEmpty()) { listState.animateScrollToItem(selectedIndex) } + viewModel.onSelectionChange(selectedIndex) } /** Advance past consecutive Header items in the given direction (+1 or -1). */ fun skipHeaders(start: Int, direction: Int): Int { - if (uiState.results.isEmpty()) return start + if (activeList.isEmpty()) return start var idx = start var guard = 0 - while (uiState.results[idx] is SearchResultItem.Header && guard < uiState.results.size) { - idx = ((idx + direction) + uiState.results.size) % uiState.results.size + while (activeList[idx] is SearchResultItem.Header && guard < activeList.size) { + idx = ((idx + direction) + activeList.size) % activeList.size guard++ } return idx @@ -84,6 +97,89 @@ fun SearchDialog( ) { val isMobile = LocalWindowSizeClass.current.isMobile + val keyEventHandler: (KeyEvent) -> Boolean = { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown) { + when (keyEvent.key) { + Key.DirectionDown -> { + if (activeList.isNotEmpty()) { + val raw = (selectedIndex + 1) % activeList.size + selectedIndex = skipHeaders(raw, 1) + } + true + } + Key.DirectionUp -> { + if (activeList.isNotEmpty()) { + val raw = if (selectedIndex <= 0) activeList.size - 1 + else selectedIndex - 1 + selectedIndex = skipHeaders(raw, -1) + } + true + } + Key.Enter -> { + if (activeList.isNotEmpty()) { + when (val item = activeList[selectedIndex]) { + is SearchResultItem.PageItem -> { + if (onPageSelected != null) { + onPageSelected(item.page.name) + } else { + onNavigateToPage(item.page.uuid) + } + onDismiss() + } + is SearchResultItem.AliasItem -> { + if (onPageSelected != null) { + onPageSelected(item.page.name) + } else { + onNavigateToPage(item.page.uuid) + } + onDismiss() + } + is SearchResultItem.BlockItem -> { + onNavigateToBlock(item.block.uuid) + onDismiss() + } + is SearchResultItem.CreatePageItem -> { + onCreatePage(item.query) + onDismiss() + } + else -> {} + } + } + true + } + Key.Escape -> { + onDismiss() + true + } + else -> false + } + } else false + } + + val sharedDecoration = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surface) + .shadow(8.dp, RoundedCornerShape(6.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + RoundedCornerShape(6.dp) + ) + .clickable(enabled = false) {} + .onKeyEvent(keyEventHandler) + + val mobileModifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .then(sharedDecoration) + + val desktopModifier = Modifier + .widthIn(min = 500.dp, max = 900.dp) + .fillMaxWidth(0.85f) + .heightIn(min = 400.dp, max = 700.dp) + .fillMaxHeight(0.75f) + .then(sharedDecoration) + val indexingIndicator: @Composable () -> Unit = { if (isIndexing) { Text( @@ -102,193 +198,150 @@ fun SearchDialog( .fillMaxSize() .clickable(onClick = onDismiss) ) { - val sharedColumnModifier = Modifier - .widthIn(max = 600.dp) - .let { if (isMobile) it.fillMaxWidth() else it.fillMaxWidth(0.8f) } - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surface) - .shadow(8.dp, RoundedCornerShape(6.dp)) - .border( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - RoundedCornerShape(6.dp) - ) - .clickable(enabled = false) {} - .onKeyEvent { keyEvent -> - if (keyEvent.type == KeyEventType.KeyDown) { - when (keyEvent.key) { - Key.DirectionDown -> { - if (uiState.results.isNotEmpty()) { - val raw = (selectedIndex + 1) % uiState.results.size - selectedIndex = skipHeaders(raw, 1) - } - true - } - Key.DirectionUp -> { - if (uiState.results.isNotEmpty()) { - val raw = if (selectedIndex <= 0) uiState.results.size - 1 - else selectedIndex - 1 - selectedIndex = skipHeaders(raw, -1) - } - true + + val resultsList: @Composable ColumnScope.() -> Unit = { + // Outer crossfade: empty-query state (recent pages) vs active search + Crossfade( + targetState = uiState.query.isBlank(), + animationSpec = tween(180), + label = "recent-to-results" + ) { showingEmpty -> + if (showingEmpty && uiState.recentPages.isNotEmpty()) { + // Show recent pages list + LazyColumn( + modifier = Modifier.heightIn(max = 300.dp).fillMaxWidth() + ) { + item { + Text( + text = "Recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) } - Key.Enter -> { - if (uiState.results.isNotEmpty()) { - when (val item = uiState.results[selectedIndex]) { - is SearchResultItem.PageItem -> { - if (onPageSelected != null) { - onPageSelected(item.page.name) - } else { - onNavigateToPage(item.page.uuid) - } - onDismiss() - } - is SearchResultItem.AliasItem -> { - if (onPageSelected != null) { - onPageSelected(item.page.name) - } else { - onNavigateToPage(item.page.uuid) - } - onDismiss() - } - is SearchResultItem.BlockItem -> { - onNavigateToBlock(item.block.uuid) - onDismiss() - } - is SearchResultItem.CreatePageItem -> { - onCreatePage(item.query) - onDismiss() + itemsIndexed(uiState.recentPages) { index, page -> + SearchResultRow( + title = page.page.name, + subtitle = page.breadcrumb ?: page.page.namespace?.replace("/", " / "), + relativeDate = formatRelativeDate(page.visitedAt ?: page.page.updatedAt), + inlineTags = page.tags.take(3), + isSelected = index == selectedIndex, + onClick = { + if (onPageSelected != null) { + onPageSelected(page.page.name) + } else { + onNavigateToPage(page.page.uuid) } - else -> {} + onDismiss() } - } - true - } - Key.Escape -> { - onDismiss() - true + ) } - else -> false - } - } else false - } - - val resultsList: @Composable ColumnScope.() -> Unit = { - if (uiState.isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } else if (uiState.query.isBlank() && uiState.recentQueries.isNotEmpty()) { - LazyColumn( - modifier = Modifier.heightIn(max = 300.dp).fillMaxWidth() - ) { - item { - Text( - text = "Recent", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) } - items(uiState.recentQueries.size) { i -> - val q = uiState.recentQueries[i] - SearchResultRow( - title = q, - subtitle = "Recent search", - isSelected = false, - onClick = { viewModel.onQueryChange(q) } - ) - } - } - } else { - LazyColumn( - state = listState, - modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth() - ) { - itemsIndexed(uiState.results) { index, item -> - when (item) { - is SearchResultItem.Header -> { - Text( - text = item.title, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) - } - is SearchResultItem.PageItem -> { - SearchResultRow( - title = item.page.name, - subtitle = item.page.namespace ?: "Page", - snippet = item.snippet, - isSelected = index == selectedIndex, - onClick = { - if (onPageSelected != null) { - onPageSelected(item.page.name) - } else { - onNavigateToPage(item.page.uuid) - } - onDismiss() - } - ) - } - is SearchResultItem.AliasItem -> { - SearchResultRow( - title = item.alias, - subtitle = "Alias for ${item.page.name}", - isSelected = index == selectedIndex, - onClick = { - if (onPageSelected != null) { - onPageSelected(item.page.name) - } else { - onNavigateToPage(item.page.uuid) + } else if (showingEmpty) { + // Empty state: no recent pages — show nothing + } else { + // Active search: skeleton or results + Crossfade( + targetState = uiState.isSkeletonVisible || uiState.isLoading, + animationSpec = tween(200), + label = "search-skeleton-fade" + ) { showingSkeleton -> + if (showingSkeleton) { + SearchSkeletonList(rowCount = 6) + } else { + Column { + LazyColumn( + state = listState, + modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth() + ) { + itemsIndexed(uiState.results) { index, item -> + when (item) { + is SearchResultItem.Header -> { + Text( + text = item.title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + is SearchResultItem.PageItem -> { + SearchResultRow( + title = item.page.name, + subtitle = item.breadcrumb ?: item.page.namespace?.replace("/", " / "), + relativeDate = formatRelativeDate(item.page.updatedAt), + inlineTags = item.tags.take(3), + snippet = item.snippet, + isSelected = index == selectedIndex, + onClick = { + if (onPageSelected != null) { + onPageSelected(item.page.name) + } else { + onNavigateToPage(item.page.uuid) + } + onDismiss() + } + ) + } + is SearchResultItem.AliasItem -> { + SearchResultRow( + title = item.alias, + subtitle = "Alias for ${item.page.name}", + isSelected = index == selectedIndex, + onClick = { + if (onPageSelected != null) { + onPageSelected(item.page.name) + } else { + onNavigateToPage(item.page.uuid) + } + onDismiss() + } + ) + } + is SearchResultItem.BlockItem -> { + SearchResultRow( + title = item.block.content.take(100), + subtitle = item.breadcrumb ?: "Block", + relativeDate = formatRelativeDate(item.block.updatedAt), + snippet = item.snippet, + isSelected = index == selectedIndex, + onClick = { + onNavigateToBlock(item.block.uuid) + onDismiss() + } + ) + } + is SearchResultItem.CreatePageItem -> { + SearchResultRow( + title = "Create page \"${item.query}\"", + subtitle = "New Page", + isSelected = index == selectedIndex, + onClick = { + onCreatePage(item.query) + onDismiss() + } + ) + } } - onDismiss() - } - ) - } - is SearchResultItem.BlockItem -> { - SearchResultRow( - title = item.block.content.take(100), - subtitle = "Block", - snippet = item.snippet, - isSelected = index == selectedIndex, - onClick = { - onNavigateToBlock(item.block.uuid) - onDismiss() } - ) - } - is SearchResultItem.CreatePageItem -> { - SearchResultRow( - title = "Create page \"${item.query}\"", - subtitle = "New Page", - isSelected = index == selectedIndex, - onClick = { - onCreatePage(item.query) - onDismiss() + } + if (uiState.results.isEmpty() && uiState.query.isNotEmpty() && !uiState.isLoading && !uiState.isSkeletonVisible) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text("No results found", color = MaterialTheme.colorScheme.onSurfaceVariant) } - ) + } } } } } - if (uiState.results.isEmpty() && uiState.query.isNotEmpty()) { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - Text("No results found", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } } } @@ -297,7 +350,7 @@ fun SearchDialog( modifier = Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.BottomCenter ) { - Column(modifier = sharedColumnModifier, verticalArrangement = Arrangement.Bottom) { + Column(modifier = mobileModifier, verticalArrangement = Arrangement.Bottom) { resultsList() indexingIndicator() HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) @@ -336,46 +389,77 @@ fun SearchDialog( } } } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Column(modifier = sharedColumnModifier.padding(top = 100.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - TextField( - value = uiState.query, - onValueChange = { viewModel.onQueryChange(it) }, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - placeholder = { Text(placeholder) }, - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(modifier = desktopModifier) { + Column(modifier = Modifier.width(340.dp).fillMaxHeight()) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextField( + value = uiState.query, + onValueChange = { viewModel.onQueryChange(it) }, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + placeholder = { Text(placeholder) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ) ) - ) - if (uiState.query.isNotEmpty()) { - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { PlainTooltip { Text("Title Case") } }, - state = rememberTooltipState() - ) { - TextButton(onClick = { viewModel.onQueryChange(uiState.query.toTitleCase()) }) { - Text("Tt", style = MaterialTheme.typography.labelMedium) + if (uiState.query.isNotEmpty()) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text("Title Case") } }, + state = rememberTooltipState() + ) { + TextButton(onClick = { viewModel.onQueryChange(uiState.query.toTitleCase()) }) { + Text("Tt", style = MaterialTheme.typography.labelMedium) + } } } } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + ActivePrefixChipRow( + parsedQuery = uiState.parsedQuery, + onRemoveTag = { + viewModel.onQueryChange( + uiState.query.replace(Regex("""#\S+"""), "").trim() + ) + }, + onRemoveScope = { + viewModel.onQueryChange( + uiState.query.replace(Regex("""/(pages?|blocks?|journal|current)\b""", RegexOption.IGNORE_CASE), "").trim() + ) + }, + onRemoveDate = { + viewModel.onQueryChange( + uiState.query.replace(Regex("""modified:(today|day|week|month|year)""", RegexOption.IGNORE_CASE), "").trim() + ) + }, + onRemoveProperty = { key -> + viewModel.onQueryChange( + uiState.query.replace(Regex("""$key::\w+"""), "").trim() + ) + } + ) + FilterBar( + currentScope = uiState.scope, + onScopeChange = { viewModel.onScopeChange(it) }, + showCurrentPage = currentPageUuid != null + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + indexingIndicator() + resultsList() } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - FilterBar( - currentScope = uiState.scope, - onScopeChange = { viewModel.onScopeChange(it) }, - showCurrentPage = currentPageUuid != null - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + PreviewPanel( + content = uiState.previewContent, + loadPageBlocks = loadPageBlocks, + modifier = Modifier.weight(1f).fillMaxHeight() ) - indexingIndicator() - resultsList() } } } @@ -383,13 +467,24 @@ fun SearchDialog( } } +/** + * Rich result row showing title + optional relative date, breadcrumb subtitle, + * inline tags (as "#tag1 \u00B7 #tag2"), and an FTS snippet. + * + * Layout (3-line budget): + * - Line 1: [title] (left, bodyMedium) + [relativeDate] (right, labelSmall) + * - Line 2: [subtitle] breadcrumb (labelSmall, dimmed) \u2014 only when non-null + * - Line 3: inline tags "#tag1 \u00B7 #tag2" or [SnippetText] \u2014 tags take priority + */ @Composable fun SearchResultRow( title: String, - subtitle: String, + subtitle: String? = null, + relativeDate: String? = null, + inlineTags: List = emptyList(), + snippet: String? = null, isSelected: Boolean, - onClick: () -> Unit, - snippet: String? = null + onClick: () -> Unit ) { Row( modifier = Modifier @@ -400,27 +495,169 @@ fun SearchResultRow( verticalAlignment = Alignment.Top ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal - ) - if (subtitle.isNotEmpty()) { + // Line 1: title + relative date + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, + modifier = Modifier.weight(1f) + ) + if (relativeDate != null) { + Text( + text = relativeDate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (isSelected) { + Text( + text = "Enter \u23CE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + } + } + // Line 2: breadcrumb subtitle + if (!subtitle.isNullOrEmpty()) { Text( text = subtitle, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1 + ) + } + // Line 3: inline tags preferred over snippet + if (inlineTags.isNotEmpty()) { + Text( + text = inlineTags.joinToString(" \u00B7 ") { "#$it" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } else { + SnippetText(snippet = snippet) + } + } + } +} + +/** Backward-compatible wrapper for callers that use positional [subtitle] without rich fields. */ +@Composable +fun SearchResultRow( + title: String, + subtitle: String, + isSelected: Boolean, + onClick: () -> Unit, + snippet: String? = null +) = SearchResultRow( + title = title, + subtitle = subtitle, + relativeDate = null, + inlineTags = emptyList(), + snippet = snippet, + isSelected = isSelected, + onClick = onClick +) + +/** + * Renders chips for active prefix filters parsed from the search query. + * Shows nothing when [parsedQuery] is null or has no active filters. + */ +@Composable +fun ActivePrefixChipRow( + parsedQuery: ParsedQuery?, + onRemoveTag: () -> Unit, + onRemoveScope: () -> Unit, + onRemoveDate: () -> Unit, + onRemoveProperty: (String) -> Unit +) { + if (parsedQuery == null) return + val hasAnyFilter = parsedQuery.tagFilter != null + || parsedQuery.scopeOverride != null + || parsedQuery.dateRange != null + || parsedQuery.propertyFilters.isNotEmpty() + if (!hasAnyFilter) return + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + parsedQuery.tagFilter?.let { tag -> + SuggestionChip( + onClick = onRemoveTag, + label = { Text("#$tag") }, + icon = { + Text( + text = "\u00D7", + style = MaterialTheme.typography.labelMedium + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer ) + ) + } + parsedQuery.scopeOverride?.let { scope -> + val label = when (scope) { + SearchScope.PAGES_ONLY -> "Pages" + SearchScope.BLOCKS_ONLY -> "Blocks" + SearchScope.JOURNAL -> "Journal" + SearchScope.CURRENT_PAGE -> "This page" + else -> scope.name } - SnippetText(snippet = snippet) + SuggestionChip( + onClick = onRemoveScope, + label = { Text(label) }, + icon = { + Text( + text = "\u00D7", + style = MaterialTheme.typography.labelMedium + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) } - if (isSelected) { - Text( - text = "Enter \u23CE", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + if (parsedQuery.dateRange != null) { + SuggestionChip( + onClick = onRemoveDate, + label = { Text("date filter") }, + icon = { + Text( + text = "\u00D7", + style = MaterialTheme.typography.labelMedium + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + for ((key, value) in parsedQuery.propertyFilters) { + SuggestionChip( + onClick = { onRemoveProperty(key) }, + label = { Text("$key: $value") }, + icon = { + Text( + text = "\u00D7", + style = MaterialTheme.typography.labelMedium + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer + ) ) } } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt new file mode 100644 index 00000000..c2b21841 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +package dev.stapler.stelekit.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +private val widthFractions = listOf(0.55f, 0.75f, 0.40f, 0.65f, 0.80f, 0.45f) + +@Composable +fun SearchSkeletonList(rowCount: Int = 6) { + val infiniteTransition = rememberInfiniteTransition(label = "skeleton") + val animatedAlpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.7f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 800, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "skeleton-alpha" + ) + + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = animatedAlpha * 0.15f) + + Column { + repeat(rowCount) { i -> + val titleFraction = widthFractions[i % widthFractions.size] + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(color) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth(titleFraction) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color) + ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .fillMaxWidth(titleFraction * 0.6f) + .height(10.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color) + ) + } + } + } + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt index 37b99940..7451ca6a 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt @@ -2,9 +2,15 @@ package dev.stapler.stelekit.ui.screens import dev.stapler.stelekit.model.Block import dev.stapler.stelekit.model.Page +import dev.stapler.stelekit.repository.DataType +import dev.stapler.stelekit.repository.DateRange +import dev.stapler.stelekit.repository.PageRepository import dev.stapler.stelekit.repository.SearchRepository import dev.stapler.stelekit.repository.SearchRequest import dev.stapler.stelekit.repository.SearchScope +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -13,41 +19,176 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.cancel import kotlinx.coroutines.launch private const val MAX_RECENT_QUERIES = 10 +// Regexes used by parseQuery — compiled once at file scope +private val MODIFIED_REGEX = Regex("""modified:(today|day|week|month|year)""", RegexOption.IGNORE_CASE) +private val TAG_REGEX = Regex("""#(\S+)""") +private val SCOPE_REGEX = Regex("""/(pages?|blocks?|journal|current)\b""", RegexOption.IGNORE_CASE) +private val PROPERTY_REGEX = Regex("""(\w+)::(\w+)""") + +data class ParsedQuery( + val ftsTerm: String, + val dateRange: DateRange? = null, + val tagFilter: String? = null, + val scopeOverride: SearchScope? = null, + val propertyFilters: Map = emptyMap() +) + +enum class ActivePrefixMode { NONE, MODIFIED_DATE, TAG, SCOPE, PROPERTY } + +sealed class PreviewPanelContent { + data class PagePreview(val pageUuid: String, val pageTitle: String) : PreviewPanelContent() + data class BlockPreview(val blockUuid: String, val pageTitle: String, val blockSnippet: String) : PreviewPanelContent() + data object Empty : PreviewPanelContent() +} + +private fun parseQuery(raw: String): ParsedQuery { + var remainder = raw + + // Extract modified: date range + val modifiedMatch = MODIFIED_REGEX.find(remainder) + var dateRange: DateRange? = null + if (modifiedMatch != null) { + val now = Clock.System.now() + val startDate = when (modifiedMatch.groupValues[1].lowercase()) { + "today", "day" -> now - 1.days + "week" -> now - 7.days + "month" -> now - 30.days + "year" -> now - 365.days + else -> now - 1.days + } + dateRange = DateRange(startDate = startDate, endDate = now) + remainder = remainder.replace(modifiedMatch.value, "") + } + + // Extract #tag filter (first occurrence) + val tagMatch = TAG_REGEX.find(remainder) + val tagFilter: String? = tagMatch?.groupValues?.get(1) + if (tagMatch != null) { + remainder = remainder.replace(tagMatch.value, "") + } + + // Extract /scope override + val scopeMatch = SCOPE_REGEX.find(remainder) + val scopeOverride: SearchScope? = scopeMatch?.let { m -> + when (m.groupValues[1].lowercase()) { + "page", "pages" -> SearchScope.PAGES_ONLY + "block", "blocks" -> SearchScope.BLOCKS_ONLY + "journal" -> SearchScope.JOURNAL + "current" -> SearchScope.CURRENT_PAGE + else -> null + } + } + if (scopeMatch != null) { + remainder = remainder.replace(scopeMatch.value, "") + } + + // Extract property filters (key::value) + val propertyFilters = mutableMapOf() + for (m in PROPERTY_REGEX.findAll(remainder).toList()) { + propertyFilters[m.groupValues[1]] = m.groupValues[2] + remainder = remainder.replace(m.value, "") + } + + return ParsedQuery( + ftsTerm = remainder.trim(), + dateRange = dateRange, + tagFilter = tagFilter, + scopeOverride = scopeOverride, + propertyFilters = propertyFilters + ) +} + +private fun activeModeFor(parsed: ParsedQuery): ActivePrefixMode = when { + parsed.dateRange != null -> ActivePrefixMode.MODIFIED_DATE + parsed.tagFilter != null -> ActivePrefixMode.TAG + parsed.scopeOverride != null -> ActivePrefixMode.SCOPE + parsed.propertyFilters.isNotEmpty() -> ActivePrefixMode.PROPERTY + else -> ActivePrefixMode.NONE +} + class SearchViewModel( private val searchRepository: SearchRepository, // Default scope owns its lifecycle; callers in remember{} must not pass rememberCoroutineScope() // which is cancelled when the composable leaves composition. Tests inject a TestCoroutineScope. - scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val pageRepository: PageRepository? = null ) { private val scope = scope private val _uiState = MutableStateFlow(SearchUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var searchJob: Job? = null + private var previewJob: Job? = null + + init { + if (pageRepository != null) { + loadRecentPages() + } + } + + private fun loadRecentPages() { + val repo = pageRepository ?: return + scope.launch { + try { + repo.getRecentPages(limit = 8).first().getOrNull()?.let { pages -> + val recentPageItems = pages.map { page -> + SearchResultItem.PageItem( + page = page, + backlinkCount = 0, + tags = parseTagsFromProperties(page.properties), + breadcrumb = page.namespace?.replace("/", " / "), + visitedAt = page.updatedAt + ) + } + _uiState.update { it.copy(recentPages = recentPageItems) } + } + } catch (_: Exception) { + // Ignore errors loading recent pages — non-critical + } + } + } fun onQueryChange(query: String) { - _uiState.update { it.copy(query = query) } + // Parse prefixes immediately (before debounce) and update active mode + skeleton + val parsed = parseQuery(query) + _uiState.update { it.copy( + query = query, + parsedQuery = parsed, + activePrefixMode = activeModeFor(parsed), + isSkeletonVisible = query.isNotBlank() + ) } searchJob?.cancel() if (query.isBlank()) { - _uiState.update { it.copy(results = emptyList(), isLoading = false) } + _uiState.update { it.copy(results = emptyList(), isLoading = false, isSkeletonVisible = false) } return } searchJob = scope.launch { - delay(300) // Debounce - _uiState.update { it.copy(isLoading = true) } + delay(300) // Debounce — skeleton shown during this window + _uiState.update { it.copy(isSkeletonVisible = false, isLoading = true) } try { + // Merge tag filter into property filters if present + val effectivePropertyFilters = if (parsed.tagFilter != null) { + parsed.propertyFilters + mapOf("tags" to parsed.tagFilter) + } else { + parsed.propertyFilters + } + val request = SearchRequest( - query = query, - scope = _uiState.value.scope, + query = parsed.ftsTerm.takeIf { it.isNotBlank() } ?: query, + scope = parsed.scopeOverride ?: _uiState.value.scope, + dataTypes = setOf(DataType.TITLES, DataType.CONTENT), + propertyFilters = effectivePropertyFilters, + dateRange = parsed.dateRange, limit = 20 ) searchRepository.searchWithFilters(request).collect { result -> @@ -55,11 +196,29 @@ class SearchViewModel( if (searchResult != null) { val items = mutableListOf() + // Build a page name map from all pages in the result for block breadcrumbs + val pageNameMap: Map = searchResult.pages.associate { it.uuid to it.name } + // Pages section — prefer searchedPages (with snippets) if available val pagedItems = if (searchResult.searchedPages.isNotEmpty()) { - searchResult.searchedPages.map { SearchResultItem.PageItem(it.page, it.snippet) } + searchResult.searchedPages.map { sp -> + SearchResultItem.PageItem( + page = sp.page, + snippet = sp.snippet, + backlinkCount = sp.backlinkCount, + tags = parseTagsFromProperties(sp.page.properties), + breadcrumb = sp.page.namespace?.replace("/", " / ") + ) + } } else { - searchResult.pages.map { SearchResultItem.PageItem(it) } + searchResult.pages.map { page -> + SearchResultItem.PageItem( + page = page, + backlinkCount = 0, + tags = parseTagsFromProperties(page.properties), + breadcrumb = page.namespace?.replace("/", " / ") + ) + } } if (pagedItems.isNotEmpty()) { items.add(SearchResultItem.Header("Pages")) @@ -68,9 +227,20 @@ class SearchViewModel( // Blocks section — prefer searchedBlocks (with snippets) if available val blockItems = if (searchResult.searchedBlocks.isNotEmpty()) { - searchResult.searchedBlocks.map { SearchResultItem.BlockItem(it.block, it.snippet) } + searchResult.searchedBlocks.map { sb -> + SearchResultItem.BlockItem( + block = sb.block, + snippet = sb.snippet, + breadcrumb = pageNameMap[sb.block.pageUuid] + ) + } } else { - searchResult.blocks.map { SearchResultItem.BlockItem(it) } + searchResult.blocks.map { block -> + SearchResultItem.BlockItem( + block = block, + breadcrumb = pageNameMap[block.pageUuid] + ) + } } if (blockItems.isNotEmpty()) { items.add(SearchResultItem.Header("Blocks")) @@ -101,16 +271,43 @@ class SearchViewModel( state.copy( results = withCreate, isLoading = false, + isSkeletonVisible = false, recentQueries = recentQueries ) } } else { - _uiState.update { it.copy(isLoading = false, error = "Search failed") } + _uiState.update { it.copy(isLoading = false, isSkeletonVisible = false, error = "Search failed") } } } } catch (e: Exception) { - _uiState.update { it.copy(isLoading = false, error = e.message) } + _uiState.update { it.copy(isLoading = false, isSkeletonVisible = false, error = e.message) } + } + } + } + + fun onSelectionChange(index: Int) { + previewJob?.cancel() + val state = _uiState.value + val list: List = if (state.query.isBlank()) state.recentPages else state.results + if (index < 0 || index >= list.size) { + _uiState.update { it.copy(previewContent = PreviewPanelContent.Empty) } + return + } + previewJob = scope.launch { + delay(150) + val preview: PreviewPanelContent = when (val item = list[index]) { + is SearchResultItem.PageItem -> PreviewPanelContent.PagePreview( + pageUuid = item.page.uuid, + pageTitle = item.page.name + ) + is SearchResultItem.BlockItem -> PreviewPanelContent.BlockPreview( + blockUuid = item.block.uuid, + pageTitle = item.breadcrumb ?: "Unknown", + blockSnippet = item.block.content.take(200) + ) + else -> PreviewPanelContent.Empty } + _uiState.update { it.copy(previewContent = preview) } } } @@ -125,21 +322,44 @@ class SearchViewModel( fun close() { scope.cancel() } + + private fun parseTagsFromProperties(properties: Map): List { + val tagsValue = properties["tags"] ?: return emptyList() + return tagsValue.split(Regex("[,\\s]+")) + .map { it.trim() } + .filter { it.isNotBlank() } + } } data class SearchUiState( val query: String = "", val results: List = emptyList(), val isLoading: Boolean = false, + val isSkeletonVisible: Boolean = false, val error: String? = null, val scope: SearchScope = SearchScope.ALL, - val recentQueries: List = emptyList() + val recentQueries: List = emptyList(), + val recentPages: List = emptyList(), + val activePrefixMode: ActivePrefixMode = ActivePrefixMode.NONE, + val parsedQuery: ParsedQuery? = null, + val previewContent: PreviewPanelContent = PreviewPanelContent.Empty ) sealed class SearchResultItem { data class Header(val title: String) : SearchResultItem() - data class PageItem(val page: Page, val snippet: String? = null) : SearchResultItem() + data class PageItem( + val page: Page, + val snippet: String? = null, + val backlinkCount: Int = 0, + val tags: List = emptyList(), + val breadcrumb: String? = null, + val visitedAt: Instant? = null + ) : SearchResultItem() data class AliasItem(val page: Page, val alias: String) : SearchResultItem() - data class BlockItem(val block: Block, val snippet: String? = null) : SearchResultItem() + data class BlockItem( + val block: Block, + val snippet: String? = null, + val breadcrumb: String? = null + ) : SearchResultItem() data class CreatePageItem(val query: String) : SearchResultItem() } diff --git a/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq b/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq index 40f9a203..0849c6e7 100644 --- a/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq +++ b/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq @@ -13,7 +13,8 @@ CREATE TABLE pages ( is_favorite INTEGER DEFAULT 0, is_journal INTEGER DEFAULT 0, journal_date TEXT, - is_content_loaded INTEGER NOT NULL DEFAULT 1 + is_content_loaded INTEGER NOT NULL DEFAULT 1, + backlink_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE blocks ( @@ -537,6 +538,32 @@ WHERE pages_fts MATCH :query ORDER BY bm25(pages_fts) LIMIT :limit; +-- Date-ranged FTS queries for search with date filters +searchPagesByNameFtsInDateRange: +SELECT p.uuid, p.name, p.namespace, p.file_path, p.created_at, p.updated_at, + p.properties, p.version, p.is_favorite, p.is_journal, p.journal_date, + p.is_content_loaded, + highlight(pages_fts, 0, '', '') AS highlight, + bm25(pages_fts) AS bm25_score +FROM pages_fts pf +JOIN pages p ON p.rowid = pf.rowid +WHERE pages_fts MATCH :query +AND p.updated_at >= :startMs AND p.updated_at <= :endMs +ORDER BY bm25(pages_fts) +LIMIT :limit; + +searchBlocksByContentFtsInDateRange: +SELECT b.uuid, b.page_uuid, b.parent_uuid, b.left_uuid, b.content, b.level, + b.position, b.created_at, b.updated_at, b.properties, b.version, + highlight(blocks_fts, 0, '', '') AS highlight, + bm25(blocks_fts) AS bm25_score +FROM blocks_fts bm +JOIN blocks b ON b.id = bm.rowid +WHERE blocks_fts MATCH :query +AND b.updated_at >= :startMs AND b.updated_at <= :endMs +ORDER BY bm25(blocks_fts) +LIMIT :limit OFFSET :offset; + -- Visit tracking queries — two-step upsert (same pattern as histogram buckets) insertPageVisitIfAbsent: INSERT OR IGNORE INTO page_visits (page_uuid, visit_count, last_visited_at) @@ -620,6 +647,30 @@ SELECT * FROM blocks WHERE content LIKE '%[[' || :pageName || ']]%' ORDER BY pag countLinkedReferencesForPage: SELECT COUNT(*) FROM blocks WHERE content LIKE '%[[' || :pageName || ']]%'; +-- Read precomputed backlink counts for a batch of pages — O(1) per page via UUID index. +-- The backlink_count column is populated by the pages_backlink_count schema migration +-- and refreshed by recomputeAllBacklinkCounts when the FTS index is rebuilt. +selectBacklinkCountsForPages: +SELECT name AS page_name, backlink_count +FROM pages +WHERE uuid IN ?; + +-- Recompute all page backlink counts from block content in one pass. +-- Called alongside FTS rebuild (rebuildFts) so counts stay accurate after bulk imports. +recomputeAllBacklinkCounts: +UPDATE pages SET backlink_count = ( + SELECT COUNT(*) FROM blocks + WHERE blocks.content LIKE '%[[' || pages.name || ']]%' +); + +-- Recompute backlink count for a single page by name. +-- Called incrementally after block writes that add/remove wikilinks. +recomputeBacklinkCountForPage: +UPDATE pages SET backlink_count = ( + SELECT COUNT(*) FROM blocks + WHERE blocks.content LIKE '%[[' || pages.name || ']]%' +) WHERE pages.name = ?; + -- Metadata table for schema versioning and graph-level facts CREATE TABLE metadata ( key TEXT NOT NULL PRIMARY KEY,