search and/or read your saved and liked bluesky posts
wails go svelte sqlite desktop bluesky
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: post search

* Svelte UI for input and data display

+407 -46
+9 -8
TODO.md
··· 39 39 40 40 ## Milestone 5 — Search & Data Table 41 41 42 - - [ ] Implement `SearchService` struct with Wails service binding 43 - - [ ] `Search(query, source)` — FTS5 query with BM25 ranking and source filter 44 - - [ ] `CountPosts()` — total indexed post count 45 - - [ ] Frontend: search bar with query input and source filter (All / Saved / Liked segmented control) 46 - - [ ] Frontend: tabbed data table component (Saved / Liked / All tabs) 47 - - [ ] Columns: Author Handle, Text (truncated), Created At, ♥ Likes, 🔁 Reposts, 💬 Replies, Source 48 - - [ ] Client-side column sorting (click header to toggle asc/desc) 49 - - [ ] Row click → open post URL in default browser via `runtime.BrowserOpenURL` 42 + - [x] Implement `SearchService` struct with Wails service binding 43 + - [x] `Search(query, source)` — FTS5 query with BM25 ranking and source filter 44 + - [x] `CountPosts()` — total indexed post count 45 + - [x] Frontend: search bar with query input and source filter (All / Saved / Liked segmented control) 46 + - [x] Frontend: tabbed data table component (Saved / Liked / All tabs) 47 + - [x] Columns: Author Handle, Text (truncated), Created At, ♥ Likes, 🔁 Reposts, 💬 Replies, Source 48 + - [x] Client-side column sorting (click header to toggle asc/desc) 49 + - [x] Row click → open post URL in default browser via `runtime.BrowserOpenURL` 50 50 51 51 ## Milestone 6 — Facets & Log Viewer 52 52 ··· 61 61 62 62 ## Milestone 7 — Polish 63 63 64 + - [ ] Animations with svelte APIs (transition, animate) 64 65 - [ ] Empty state: show "No posts indexed" with prompt to refresh 65 66 - [ ] Error handling: toast/notification for network failures, auth expiry 66 67 - [ ] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer
+12 -14
app.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "fmt" 6 5 "os" 7 6 "path/filepath" 7 + 8 + "github.com/wailsapp/wails/v2/pkg/runtime" 8 9 ) 9 10 10 11 // App struct 11 12 type App struct { 12 - ctx context.Context 13 - authService *AuthService 14 - indexService *IndexService 13 + ctx context.Context 14 + authService *AuthService 15 + indexService *IndexService 16 + searchService *SearchService 15 17 } 16 18 17 19 // NewApp creates a new App application struct 18 20 func NewApp() *App { 19 21 return &App{ 20 - authService: NewAuthService(), 21 - indexService: NewIndexService(), 22 + authService: NewAuthService(), 23 + indexService: NewIndexService(), 24 + searchService: NewSearchService(), 22 25 } 23 26 } 24 27 ··· 31 34 32 35 dbPath := getDBPath() 33 36 if err := Open(dbPath); err != nil { 34 - fmt.Printf("failed to open database: %v\n", err) 37 + runtime.LogErrorf(a.ctx, "failed to open database: %v", err) 35 38 return 36 39 } 37 40 38 41 if a.authService.IsAuthenticated() { 39 42 if err := a.authService.RefreshSession(); err != nil { 40 - fmt.Printf("token refresh failed on startup: %v\n", err) 43 + runtime.LogWarningf(a.ctx, "token refresh failed on startup: %v", err) 41 44 } 42 45 } 43 46 } ··· 45 48 // shutdown is called when the app shuts down 46 49 func (a *App) shutdown(ctx context.Context) { 47 50 if err := Close(); err != nil { 48 - fmt.Printf("failed to close database: %v\n", err) 51 + runtime.LogErrorf(ctx, "failed to close database: %v", err) 49 52 } 50 53 } 51 54 ··· 63 66 64 67 return filepath.Join(configDir, "bsky-browser", "bsky-browser.db") 65 68 } 66 - 67 - // Greet returns a greeting for the given name 68 - func (a *App) Greet(name string) string { 69 - return fmt.Sprintf("Hello %s, It's show time!", name) 70 - }
+1 -1
frontend/package.json.md5
··· 1 - 594a3cbf221ff3dc93082fe39bc9c084 1 + 02bc6d89a1b36e6363147a6cafdca3d7
+95 -11
frontend/src/App.svelte
··· 5 5 import { onMount } from "svelte"; 6 6 import { Login, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; 7 7 import { Refresh, IsIndexing } from "../wailsjs/go/main/IndexService"; 8 + import { Search, CountPosts } from "../wailsjs/go/main/SearchService"; 8 9 import { EventsOn } from "../wailsjs/runtime/runtime"; 10 + import SearchBar from "./lib/components/SearchBar.svelte"; 11 + import DataTable from "./lib/components/DataTable.svelte"; 12 + import type { main } from "../wailsjs/go/models"; 9 13 10 14 type AuthInfo = { handle: string; did: string }; 15 + 11 16 type IndexStats = { fetched: number; inserted: number; errors: number; total: number }; 12 17 13 18 let handle = $state(""); ··· 19 24 let refreshLimit = $state(0); 20 25 let indexStats = $state<IndexStats>({ fetched: 0, inserted: 0, errors: 0, total: 0 }); 21 26 let showProgress = $state(false); 27 + let searchQuery = $state(""); 28 + let searchSource = $state(""); 29 + let searchResults = $state<main.SearchResult[]>([]); 30 + let totalPosts = $state(0); 31 + let sortColumn = $state("created_at"); 32 + let sortDirection = $state<"asc" | "desc">("desc"); 33 + let isSearching = $state(false); 22 34 23 35 onMount(async () => { 24 36 await checkAuthStatus(); ··· 36 48 EventsOn("index:done", (result: any) => { 37 49 isIndexing = false; 38 50 indexStats.total = result.total || 0; 51 + loadPosts(); 39 52 setTimeout(() => { 40 53 showProgress = false; 41 54 }, 3000); ··· 45 58 if (isIndexing) { 46 59 showProgress = true; 47 60 } 61 + 62 + await loadPosts(); 48 63 }); 49 64 50 65 async function checkAuthStatus() { ··· 94 109 } 95 110 } 96 111 112 + async function loadPosts() { 113 + try { 114 + totalPosts = await CountPosts(); 115 + await performSearch(searchQuery, searchSource); 116 + } catch (err) { 117 + console.error("Failed to load posts:", err); 118 + } 119 + } 120 + 121 + async function performSearch(query: string, source: string) { 122 + if (!query.trim()) { 123 + query = "*"; 124 + } 125 + 126 + isSearching = true; 127 + try { 128 + const results = await Search(query, source); 129 + searchResults = sortResults(results); 130 + } catch (err) { 131 + console.error("Search failed:", err); 132 + searchResults = []; 133 + } finally { 134 + isSearching = false; 135 + } 136 + } 137 + 138 + function sortResults(results: main.SearchResult[]): main.SearchResult[] { 139 + return [...results].sort((a, b) => { 140 + let aVal: any = a[sortColumn as keyof main.SearchResult]; 141 + let bVal: any = b[sortColumn as keyof main.SearchResult]; 142 + 143 + if (sortColumn === "created_at" || sortColumn === "indexed_at") { 144 + aVal = aVal ? new Date(aVal).getTime() : 0; 145 + bVal = bVal ? new Date(bVal).getTime() : 0; 146 + } 147 + 148 + if (typeof aVal === "number" && typeof bVal === "number") { 149 + return sortDirection === "asc" ? aVal - bVal : bVal - aVal; 150 + } 151 + 152 + const aStr = String(aVal || "").toLowerCase(); 153 + const bStr = String(bVal || "").toLowerCase(); 154 + 155 + if (sortDirection === "asc") { 156 + return aStr < bStr ? -1 : aStr > bStr ? 1 : 0; 157 + } else { 158 + return aStr > bStr ? -1 : aStr < bStr ? 1 : 0; 159 + } 160 + }); 161 + } 162 + 163 + function handleSort(column: string) { 164 + if (sortColumn === column) { 165 + sortDirection = sortDirection === "asc" ? "desc" : "asc"; 166 + } else { 167 + sortColumn = column; 168 + sortDirection = "desc"; 169 + } 170 + searchResults = sortResults(searchResults); 171 + } 172 + 97 173 function handleKeydown(event: KeyboardEvent) { 98 174 if (event.key === "Enter" && !isLoading) { 99 175 handleLogin(); ··· 101 177 } 102 178 </script> 103 179 104 - <main class="min-h-screen bg-black text-[#e5e5e5] flex flex-col"> 180 + <main class="min-h-screen bg-black text-bright flex flex-col"> 105 181 {#if !isLoggedIn} 106 182 <!-- Login View --> 107 183 <div class="flex-1 flex items-center justify-center p-4"> ··· 122 198 bind:value={handle} 123 199 onkeydown={handleKeydown} 124 200 disabled={isLoading} 125 - class="w-full bg-black border border-outline rounded px-4 py-2 font-mono text-sm text-[#e5e5e5] placeholder-[#333] focus:outline-none focus:border-[#333] disabled:opacity-50" /> 201 + class="w-full bg-black border border-outline rounded px-4 py-2 font-mono text-sm text-bright placeholder-[#333] focus:outline-none focus:border-[#333] disabled:opacity-50" /> 126 202 </div> 127 203 128 204 <button 129 205 onclick={handleLogin} 130 206 disabled={isLoading || !handle.trim()} 131 - class="w-full bg-surface border border-outline hover:bg-outline text-[#e5e5e5] font-sans py-2 px-4 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 207 + class="w-full bg-surface border border-outline hover:bg-outline text-bright font-sans py-2 px-4 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 132 208 {#if isLoading} 133 209 <span class="animate-pulse">Authenticating...</span> 134 210 {:else} ··· 153 229 <div class="flex items-center justify-between"> 154 230 <div> 155 231 <h1 class="font-serif text-xl">bsky-browser</h1> 156 - <p class="font-mono text-xs text-muted">@{authInfo?.handle}</p> 232 + <p class="font-mono text-xs text-muted">@{authInfo?.handle} · {totalPosts} posts indexed</p> 157 233 </div> 158 234 159 235 <div class="flex items-center gap-3"> ··· 165 241 min="0" 166 242 bind:value={refreshLimit} 167 243 disabled={isIndexing} 168 - class="w-20 bg-black border border-outline rounded px-2 py-1 font-mono text-sm text-[#e5e5e5] focus:outline-none focus:border-[#333] disabled:opacity-50" /> 244 + class="w-20 bg-black border border-outline rounded px-2 py-1 font-mono text-sm text-bright focus:outline-none focus:border-[#333] disabled:opacity-50" /> 169 245 </div> 170 246 171 247 <button 172 248 onclick={handleRefresh} 173 249 disabled={isIndexing} 174 - class="bg-surface border border-outline hover:bg-outline text-[#e5e5e5] font-sans py-2 px-4 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 250 + class="bg-surface border border-outline hover:bg-outline text-bright font-sans py-2 px-4 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 175 251 {#if isIndexing} 176 252 <span class="animate-pulse">Refreshing...</span> 177 253 {:else} ··· 181 257 </div> 182 258 </div> 183 259 </header> 260 + 261 + <!-- Search Bar --> 262 + <div class="px-6 py-4 border-b border-outline"> 263 + <SearchBar bind:query={searchQuery} bind:source={searchSource} onSearch={performSearch} /> 264 + </div> 184 265 185 266 <!-- Main Content --> 186 - <div class="flex-1 p-6"> 187 - <div class="text-center py-12"> 188 - <p class="font-sans text-muted">Search functionality coming in the next milestone...</p> 189 - <p class="font-mono text-xs text-[#333] mt-4">Use the Refresh button to fetch your bookmarks and likes</p> 190 - </div> 267 + <div class="flex-1 p-6 overflow-hidden"> 268 + {#if isSearching} 269 + <div class="flex items-center justify-center h-full"> 270 + <span class="font-sans text-muted animate-pulse">Searching...</span> 271 + </div> 272 + {:else} 273 + <DataTable posts={searchResults} {sortColumn} {sortDirection} onSort={handleSort} /> 274 + {/if} 191 275 </div> 192 276 193 277 <!-- Progress Bar (bottom pinned) -->
+1
frontend/src/index.css
··· 11 11 --color-primary: #33b1ff; 12 12 --color-primary-bright: #0f62fe; 13 13 --color-secondary: #ee5396; 14 + --color-bright: #e5e5e5; 14 15 } 15 16 16 17 html {
+128
frontend/src/lib/components/DataTable.svelte
··· 1 + <script lang="ts"> 2 + import { BrowserOpenURL } from "../../../wailsjs/runtime/runtime"; 3 + import type { main } from "../../../wailsjs/go/models"; 4 + 5 + interface Props { 6 + posts: main.SearchResult[]; 7 + sortColumn: string; 8 + sortDirection: "asc" | "desc"; 9 + onSort: (column: string) => void; 10 + } 11 + 12 + let { posts, sortColumn, sortDirection, onSort }: Props = $props(); 13 + 14 + const columns = [ 15 + { key: "author_handle", label: "Author", width: "w-32" }, 16 + { key: "text", label: "Text", width: "flex-1" }, 17 + { key: "created_at", label: "Created", width: "w-32" }, 18 + { key: "like_count", label: "♥", width: "w-16" }, 19 + { key: "repost_count", label: "🔁", width: "w-16" }, 20 + { key: "reply_count", label: "💬", width: "w-16" }, 21 + { key: "source", label: "Source", width: "w-20" }, 22 + ]; 23 + 24 + function formatDate(dateStr: string): string { 25 + if (!dateStr) return "-"; 26 + const date = new Date(dateStr); 27 + return date.toLocaleDateString("en-US", { 28 + month: "short", 29 + day: "numeric", 30 + year: "numeric", 31 + }); 32 + } 33 + 34 + function truncateText(text: string, maxLength: number = 120): string { 35 + if (!text) return ""; 36 + if (text.length <= maxLength) return text; 37 + return text.slice(0, maxLength) + "..."; 38 + } 39 + 40 + /** 41 + * Convert at:// URI to bsky.app URL 42 + * at://did:plc:xxx/app.bsky.feed.post/xxx -> https://bsky.app/profile/did:plc:xxx/post/xxx 43 + */ 44 + function buildPostURL(uri: string): string { 45 + const match = uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/); 46 + if (match) { 47 + return `https://bsky.app/profile/${match[1]}/post/${match[2]}`; 48 + } 49 + return uri; 50 + } 51 + 52 + function handleRowClick(uri: string) { 53 + const url = buildPostURL(uri); 54 + BrowserOpenURL(url); 55 + } 56 + 57 + function getSortIcon(column: string): string { 58 + if (sortColumn !== column) return "↕"; 59 + return sortDirection === "asc" ? "↑" : "↓"; 60 + } 61 + </script> 62 + 63 + <div class="border border-outline rounded-lg overflow-hidden bg-surface"> 64 + <div class="overflow-x-auto"> 65 + <table class="w-full"> 66 + <thead class="bg-black border-b border-outline"> 67 + <tr> 68 + {#each columns as column} 69 + <th 70 + class="px-4 py-3 text-left font-sans text-xs text-muted cursor-pointer hover:text-bright select-none {column.width}" 71 + onclick={() => onSort(column.key)}> 72 + <div class="flex items-center gap-1"> 73 + <span>{column.label}</span> 74 + <span class="font-mono text-[10px]">{getSortIcon(column.key)}</span> 75 + </div> 76 + </th> 77 + {/each} 78 + </tr> 79 + </thead> 80 + 81 + <tbody class="divide-y divide-outline"> 82 + {#each posts as post} 83 + <tr class="hover:bg-black/50 cursor-pointer transition-colors group" onclick={() => handleRowClick(post.uri)}> 84 + <td class="px-4 py-3 font-mono text-xs text-muted truncate"> 85 + @{post.author_handle} 86 + </td> 87 + 88 + <td class="px-4 py-3 font-mono text-sm text-bright"> 89 + <div class="line-clamp-2">{truncateText(post.text)}</div> 90 + </td> 91 + 92 + <td class="px-4 py-3 font-mono text-xs text-muted"> 93 + {formatDate(post.created_at)} 94 + </td> 95 + 96 + <td class="px-4 py-3 font-mono text-xs text-bright text-center"> 97 + {post.like_count || 0} 98 + </td> 99 + 100 + <td class="px-4 py-3 font-mono text-xs text-bright text-center"> 101 + {post.repost_count || 0} 102 + </td> 103 + 104 + <td class="px-4 py-3 font-mono text-xs text-bright text-center"> 105 + {post.reply_count || 0} 106 + </td> 107 + 108 + <td class="px-4 py-3"> 109 + <span 110 + class="font-sans text-xs px-2 py-0.5 rounded-full {post.source === 'saved' 111 + ? 'bg-blue-500/20 text-blue-400' 112 + : 'bg-pink-500/20 text-pink-400'}"> 113 + {post.source} 114 + </span> 115 + </td> 116 + </tr> 117 + {:else} 118 + <tr> 119 + <td colspan={columns.length} class="px-4 py-12 text-center"> 120 + <p class="font-sans text-muted">No posts found</p> 121 + <p class="font-mono text-xs text-[#333] mt-2">Try searching or refreshing your data</p> 122 + </td> 123 + </tr> 124 + {/each} 125 + </tbody> 126 + </table> 127 + </div> 128 + </div>
+67
frontend/src/lib/components/SearchBar.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + query: string; 4 + source: string; 5 + onSearch: (query: string, source: string) => void; 6 + } 7 + 8 + let { query = $bindable(), source = $bindable(), onSearch }: Props = $props(); 9 + 10 + const sources = [ 11 + { value: "", label: "All" }, 12 + { value: "saved", label: "Saved" }, 13 + { value: "liked", label: "Liked" }, 14 + ]; 15 + 16 + function handleSubmit(e: Event) { 17 + e.preventDefault(); 18 + onSearch(query, source); 19 + } 20 + 21 + function handleKeydown(e: KeyboardEvent) { 22 + if (e.key === "Enter") { 23 + onSearch(query, source); 24 + } 25 + } 26 + 27 + function handleClick(src: string) { 28 + source = src; 29 + onSearch(query, source); 30 + } 31 + </script> 32 + 33 + <form onsubmit={handleSubmit} class="flex items-center gap-3"> 34 + <div class="flex-1 relative"> 35 + <input 36 + type="text" 37 + placeholder="Search posts..." 38 + bind:value={query} 39 + onkeydown={handleKeydown} 40 + class="w-full bg-black border border-outline rounded-lg px-4 py-2.5 font-mono text-sm text-muted placeholder-[#333] focus:outline-none focus:border-[#333]" /> 41 + <div class="absolute right-3 top-1/2 -translate-y-1/2"> 42 + <kbd class="font-mono text-xs text-muted bg-surface px-2 py-0.5 rounded border border-outline">⌘K</kbd> 43 + </div> 44 + </div> 45 + 46 + <div class="flex bg-surface rounded-lg border border-outline p-0.5"> 47 + {#each sources as s} 48 + <button 49 + type="button" 50 + onclick={() => { 51 + handleClick(s.value); 52 + }} 53 + class="px-3 py-1.5 font-sans text-xs rounded transition-colors {source === s.value 54 + ? 'bg-outline text-bright' 55 + : 'text-muted hover:text-bright'} 56 + "> 57 + {s.label} 58 + </button> 59 + {/each} 60 + </div> 61 + 62 + <button 63 + type="submit" 64 + class="bg-surface border border-outline hover:bg-outline text-bright font-sans text-sm px-4 py-2 rounded-lg transition-colors"> 65 + Search 66 + </button> 67 + </form>
-4
frontend/wailsjs/go/main/App.d.ts
··· 1 - // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 2 - // This file is automatically generated. DO NOT EDIT 3 - 4 - export function Greet(arg1:string):Promise<string>;
-7
frontend/wailsjs/go/main/App.js
··· 1 - // @ts-check 2 - // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 3 - // This file is automatically generated. DO NOT EDIT 4 - 5 - export function Greet(arg1) { 6 - return window['go']['main']['App']['Greet'](arg1); 7 - }
+7
frontend/wailsjs/go/main/SearchService.d.ts
··· 1 + // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 2 + // This file is automatically generated. DO NOT EDIT 3 + import {main} from '../models'; 4 + 5 + export function CountPosts():Promise<number>; 6 + 7 + export function Search(arg1:string,arg2:string):Promise<Array<main.SearchResult>>;
+11
frontend/wailsjs/go/main/SearchService.js
··· 1 + // @ts-check 2 + // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 3 + // This file is automatically generated. DO NOT EDIT 4 + 5 + export function CountPosts() { 6 + return window['go']['main']['SearchService']['CountPosts'](); 7 + } 8 + 9 + export function Search(arg1, arg2) { 10 + return window['go']['main']['SearchService']['Search'](arg1, arg2); 11 + }
+56
frontend/wailsjs/go/models.ts
··· 55 55 return a; 56 56 } 57 57 } 58 + export class SearchResult { 59 + uri: string; 60 + cid: string; 61 + author_did: string; 62 + author_handle: string; 63 + text: string; 64 + // Go type: time 65 + created_at: any; 66 + like_count: number; 67 + repost_count: number; 68 + reply_count: number; 69 + source: string; 70 + facets: string; 71 + // Go type: time 72 + indexed_at: any; 73 + rank: number; 74 + 75 + static createFrom(source: any = {}) { 76 + return new SearchResult(source); 77 + } 78 + 79 + constructor(source: any = {}) { 80 + if ('string' === typeof source) source = JSON.parse(source); 81 + this.uri = source["uri"]; 82 + this.cid = source["cid"]; 83 + this.author_did = source["author_did"]; 84 + this.author_handle = source["author_handle"]; 85 + this.text = source["text"]; 86 + this.created_at = this.convertValues(source["created_at"], null); 87 + this.like_count = source["like_count"]; 88 + this.repost_count = source["repost_count"]; 89 + this.reply_count = source["reply_count"]; 90 + this.source = source["source"]; 91 + this.facets = source["facets"]; 92 + this.indexed_at = this.convertValues(source["indexed_at"], null); 93 + this.rank = source["rank"]; 94 + } 95 + 96 + convertValues(a: any, classs: any, asMap: boolean = false): any { 97 + if (!a) { 98 + return a; 99 + } 100 + if (a.slice && a.map) { 101 + return (a as any[]).map(elem => this.convertValues(elem, classs)); 102 + } else if ("object" === typeof a) { 103 + if (asMap) { 104 + for (const key of Object.keys(a)) { 105 + a[key] = new classs(a[key]); 106 + } 107 + return a; 108 + } 109 + return new classs(a); 110 + } 111 + return a; 112 + } 113 + } 58 114 59 115 } 60 116
frontend/wailsjs/runtime/package.json
frontend/wailsjs/runtime/runtime.d.ts
frontend/wailsjs/runtime/runtime.js
+1 -1
main.go
··· 22 22 BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 1}, 23 23 OnStartup: app.startup, 24 24 OnShutdown: app.shutdown, 25 - Bind: []any{app, app.authService, app.indexService}, 25 + Bind: []any{app, app.authService, app.indexService, app.searchService}, 26 26 }) 27 27 28 28 if err != nil {
+19
search_service.go
··· 1 + package main 2 + 3 + // SearchService provides search functionality via Wails bindings 4 + type SearchService struct{} 5 + 6 + // NewSearchService creates a new SearchService instance 7 + func NewSearchService() *SearchService { 8 + return &SearchService{} 9 + } 10 + 11 + // Search performs an FTS5 search with BM25 ranking and optional source filter 12 + func (s *SearchService) Search(query string, source string) ([]SearchResult, error) { 13 + return SearchPosts(query, source) 14 + } 15 + 16 + // CountPosts returns the total number of indexed posts 17 + func (s *SearchService) CountPosts() (int, error) { 18 + return CountPosts() 19 + }