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: bluesky indexer

+779 -65
+7 -7
TODO.md
··· 29 29 30 30 ## Milestone 4 — Indexing & Progress 31 31 32 - - [ ] Implement `IndexService` struct with Wails service binding 33 - - [ ] `Refresh(limit)` — concurrent bookmark + like fetch, batch insert (port `RefreshAndIndex` logic) 34 - - [ ] Populate `facets` column from `FeedPost.Facets` during `convertPostView` 35 - - [ ] `IsIndexing()` — thread-safe boolean guard to prevent concurrent refreshes 36 - - [ ] Emit Wails events: `index:started`, `index:progress`, `index:done` 37 - - [ ] Frontend: "Refresh" button in header, optional limit input 38 - - [ ] Frontend: bottom-pinned progress bar component driven by `index:*` events 32 + - [x] Implement `IndexService` struct with Wails service binding 33 + - [x] `Refresh(limit)` — concurrent bookmark + like fetch, batch insert (port `RefreshAndIndex` logic) 34 + - [x] Populate `facets` column from `FeedPost.Facets` during `convertPostView` 35 + - [x] `IsIndexing()` — thread-safe boolean guard to prevent concurrent refreshes 36 + - [x] Emit Wails events: `index:started`, `index:progress`, `index:done` 37 + - [x] Frontend: "Refresh" button in header, optional limit input 38 + - [x] Frontend: bottom-pinned progress bar component driven by `index:*` events 39 39 40 40 ## Milestone 5 — Search & Data Table 41 41
+10 -10
app.go
··· 5 5 "fmt" 6 6 "os" 7 7 "path/filepath" 8 - 9 - "github.com/wailsapp/wails/v2/pkg/runtime" 10 8 ) 11 9 12 10 // App struct 13 11 type App struct { 14 - ctx context.Context 15 - authService *AuthService 12 + ctx context.Context 13 + authService *AuthService 14 + indexService *IndexService 16 15 } 17 16 18 17 // NewApp creates a new App application struct 19 18 func NewApp() *App { 20 19 return &App{ 21 - authService: NewAuthService(), 20 + authService: NewAuthService(), 21 + indexService: NewIndexService(), 22 22 } 23 23 } 24 24 25 - // startup is called when the app starts. 26 - // 27 - // The context is saved so we can call the runtime methods 25 + // startup is called when the app starts. The context is saved so we can call 26 + // the runtime methods. 28 27 func (a *App) startup(ctx context.Context) { 29 28 a.ctx = ctx 29 + 30 + a.indexService.SetContext(ctx) 30 31 31 32 dbPath := getDBPath() 32 33 if err := Open(dbPath); err != nil { ··· 43 44 44 45 // shutdown is called when the app shuts down 45 46 func (a *App) shutdown(ctx context.Context) { 46 - runtime.LogInfo(ctx, "Shutting down") 47 47 if err := Close(); err != nil { 48 - runtime.LogErrorf(ctx, "failed to close database: %v", err) 48 + fmt.Printf("failed to close database: %v\n", err) 49 49 } 50 50 } 51 51
+2
frontend/package.json
··· 10 10 "check": "svelte-check --tsconfig ./tsconfig.json" 11 11 }, 12 12 "devDependencies": { 13 + "@egoist/tailwindcss-icons": "^1.9.2", 14 + "@iconify-json/ri": "^1.2.10", 13 15 "@sveltejs/vite-plugin-svelte": "^6.2.4", 14 16 "svelte": "^5.53.12", 15 17 "svelte-check": "^4.4.5",
+90
frontend/pnpm-lock.yaml
··· 24 24 specifier: ^4.2.1 25 25 version: 4.2.1 26 26 devDependencies: 27 + '@egoist/tailwindcss-icons': 28 + specifier: ^1.9.2 29 + version: 1.9.2(tailwindcss@4.2.1) 30 + '@iconify-json/ri': 31 + specifier: ^1.2.10 32 + version: 1.2.10 27 33 '@sveltejs/vite-plugin-svelte': 28 34 specifier: ^6.2.4 29 35 version: 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) ··· 44 50 version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) 45 51 46 52 packages: 53 + 54 + '@antfu/install-pkg@1.1.0': 55 + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} 56 + 57 + '@egoist/tailwindcss-icons@1.9.2': 58 + resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==} 59 + peerDependencies: 60 + tailwindcss: '*' 47 61 48 62 '@esbuild/aix-ppc64@0.27.4': 49 63 resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} ··· 209 223 210 224 '@fontsource-variable/lora@5.2.8': 211 225 resolution: {integrity: sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg==} 226 + 227 + '@iconify-json/ri@1.2.10': 228 + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} 229 + 230 + '@iconify/types@2.0.0': 231 + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} 232 + 233 + '@iconify/utils@3.1.0': 234 + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} 212 235 213 236 '@jridgewell/gen-mapping@0.3.13': 214 237 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} ··· 492 515 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} 493 516 engines: {node: '>=6'} 494 517 518 + confbox@0.1.8: 519 + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} 520 + 495 521 deepmerge@4.3.1: 496 522 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} 497 523 engines: {node: '>=0.10.0'} ··· 618 644 magic-string@0.30.21: 619 645 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 620 646 647 + mlly@1.8.1: 648 + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} 649 + 621 650 mri@1.2.0: 622 651 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 623 652 engines: {node: '>=4'} ··· 630 659 obug@2.1.1: 631 660 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 632 661 662 + package-manager-detector@1.6.0: 663 + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} 664 + 665 + pathe@2.0.3: 666 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 667 + 633 668 picocolors@1.1.1: 634 669 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 635 670 ··· 637 672 resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 638 673 engines: {node: '>=12'} 639 674 675 + pkg-types@1.3.1: 676 + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} 677 + 640 678 postcss@8.5.8: 641 679 resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} 642 680 engines: {node: ^10 || ^12 || >=14} ··· 677 715 resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} 678 716 engines: {node: '>=6'} 679 717 718 + tinyexec@1.0.4: 719 + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} 720 + engines: {node: '>=18'} 721 + 680 722 tinyglobby@0.2.15: 681 723 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 682 724 engines: {node: '>=12.0.0'} ··· 688 730 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 689 731 engines: {node: '>=14.17'} 690 732 hasBin: true 733 + 734 + ufo@1.6.3: 735 + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} 691 736 692 737 vite@7.3.1: 693 738 resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} ··· 742 787 743 788 snapshots: 744 789 790 + '@antfu/install-pkg@1.1.0': 791 + dependencies: 792 + package-manager-detector: 1.6.0 793 + tinyexec: 1.0.4 794 + 795 + '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.2.1)': 796 + dependencies: 797 + '@iconify/utils': 3.1.0 798 + tailwindcss: 4.2.1 799 + 745 800 '@esbuild/aix-ppc64@0.27.4': 746 801 optional: true 747 802 ··· 825 880 '@fontsource-variable/jetbrains-mono@5.2.8': {} 826 881 827 882 '@fontsource-variable/lora@5.2.8': {} 883 + 884 + '@iconify-json/ri@1.2.10': 885 + dependencies: 886 + '@iconify/types': 2.0.0 887 + 888 + '@iconify/types@2.0.0': {} 889 + 890 + '@iconify/utils@3.1.0': 891 + dependencies: 892 + '@antfu/install-pkg': 1.1.0 893 + '@iconify/types': 2.0.0 894 + mlly: 1.8.1 828 895 829 896 '@jridgewell/gen-mapping@0.3.13': 830 897 dependencies: ··· 1027 1094 1028 1095 clsx@2.1.1: {} 1029 1096 1097 + confbox@0.1.8: {} 1098 + 1030 1099 deepmerge@4.3.1: {} 1031 1100 1032 1101 detect-libc@2.1.2: {} ··· 1144 1213 dependencies: 1145 1214 '@jridgewell/sourcemap-codec': 1.5.5 1146 1215 1216 + mlly@1.8.1: 1217 + dependencies: 1218 + acorn: 8.16.0 1219 + pathe: 2.0.3 1220 + pkg-types: 1.3.1 1221 + ufo: 1.6.3 1222 + 1147 1223 mri@1.2.0: {} 1148 1224 1149 1225 nanoid@3.3.11: {} 1150 1226 1151 1227 obug@2.1.1: {} 1152 1228 1229 + package-manager-detector@1.6.0: {} 1230 + 1231 + pathe@2.0.3: {} 1232 + 1153 1233 picocolors@1.1.1: {} 1154 1234 1155 1235 picomatch@4.0.3: {} 1236 + 1237 + pkg-types@1.3.1: 1238 + dependencies: 1239 + confbox: 0.1.8 1240 + mlly: 1.8.1 1241 + pathe: 2.0.3 1156 1242 1157 1243 postcss@8.5.8: 1158 1244 dependencies: ··· 1234 1320 1235 1321 tapable@2.3.0: {} 1236 1322 1323 + tinyexec@1.0.4: {} 1324 + 1237 1325 tinyglobby@0.2.15: 1238 1326 dependencies: 1239 1327 fdir: 6.5.0(picomatch@4.0.3) ··· 1242 1330 tslib@2.8.1: {} 1243 1331 1244 1332 typescript@5.9.3: {} 1333 + 1334 + ufo@1.6.3: {} 1245 1335 1246 1336 vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1): 1247 1337 dependencies:
+2
frontend/pnpm-workspace.yaml
··· 1 + onlyBuiltDependencies: 2 + - esbuild
+146 -47
frontend/src/App.svelte
··· 4 4 import "@fontsource-variable/lora"; 5 5 import { onMount } from "svelte"; 6 6 import { Login, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; 7 + import { Refresh, IsIndexing } from "../wailsjs/go/main/IndexService"; 8 + import { EventsOn } from "../wailsjs/runtime/runtime"; 7 9 8 10 type AuthInfo = { handle: string; did: string }; 11 + type IndexStats = { fetched: number; inserted: number; errors: number; total: number }; 9 12 10 13 let handle = $state(""); 11 14 let isLoading = $state(false); 12 15 let status = $state(""); 13 16 let isLoggedIn = $state(false); 14 17 let authInfo = $state<AuthInfo | null>(null); 18 + let isIndexing = $state(false); 19 + let refreshLimit = $state(0); 20 + let indexStats = $state<IndexStats>({ fetched: 0, inserted: 0, errors: 0, total: 0 }); 21 + let showProgress = $state(false); 15 22 16 23 onMount(async () => { 17 24 await checkAuthStatus(); 25 + 26 + EventsOn("index:started", () => { 27 + isIndexing = true; 28 + showProgress = true; 29 + indexStats = { fetched: 0, inserted: 0, errors: 0, total: 0 }; 30 + }); 31 + 32 + EventsOn("index:progress", (stats: any) => { 33 + indexStats = stats; 34 + }); 35 + 36 + EventsOn("index:done", (result: any) => { 37 + isIndexing = false; 38 + indexStats.total = result.total || 0; 39 + setTimeout(() => { 40 + showProgress = false; 41 + }, 3000); 42 + }); 43 + 44 + isIndexing = await IsIndexing(); 45 + if (isIndexing) { 46 + showProgress = true; 47 + } 18 48 }); 19 49 20 50 async function checkAuthStatus() { ··· 54 84 } 55 85 } 56 86 87 + async function handleRefresh() { 88 + if (isIndexing) return; 89 + 90 + try { 91 + await Refresh(refreshLimit); 92 + } catch (err) { 93 + status = `Refresh failed: ${err}`; 94 + } 95 + } 96 + 57 97 function handleKeydown(event: KeyboardEvent) { 58 98 if (event.key === "Enter" && !isLoading) { 59 99 handleLogin(); ··· 61 101 } 62 102 </script> 63 103 64 - <main class="min-h-screen bg-black text-[#e5e5e5] flex items-center justify-center p-4"> 65 - <div class="w-full max-w-md"> 66 - {#if !isLoggedIn} 67 - <div class="text-center mb-8"> 68 - <h1 class="font-serif text-4xl mb-2">bsky-browser</h1> 69 - <p class="font-mono text-muted text-sm">Search your Bluesky bookmarks and likes</p> 104 + <main class="min-h-screen bg-black text-[#e5e5e5] flex flex-col"> 105 + {#if !isLoggedIn} 106 + <!-- Login View --> 107 + <div class="flex-1 flex items-center justify-center p-4"> 108 + <div class="w-full max-w-md"> 109 + <div class="text-center mb-8"> 110 + <h1 class="font-serif text-4xl mb-2">bsky-browser</h1> 111 + <p class="font-mono text-muted text-sm">Search your Bluesky bookmarks and likes</p> 112 + </div> 113 + 114 + <div class="bg-surface border border-outline rounded-lg p-6"> 115 + <div class="space-y-4"> 116 + <div> 117 + <label for="handle" class="block font-sans text-sm text-muted mb-2"> Bluesky Handle </label> 118 + <input 119 + id="handle" 120 + type="text" 121 + placeholder="username.bsky.social" 122 + bind:value={handle} 123 + onkeydown={handleKeydown} 124 + 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" /> 126 + </div> 127 + 128 + <button 129 + onclick={handleLogin} 130 + 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"> 132 + {#if isLoading} 133 + <span class="animate-pulse">Authenticating...</span> 134 + {:else} 135 + Login with Bluesky 136 + {/if} 137 + </button> 138 + </div> 139 + 140 + {#if status} 141 + <div class="mt-4 p-3 bg-black border border-outline rounded"> 142 + <p class="font-mono text-xs text-muted">{status}</p> 143 + </div> 144 + {/if} 145 + </div> 70 146 </div> 147 + </div> 148 + {:else} 149 + <!-- Main View --> 150 + <div class="flex-1 flex flex-col"> 151 + <!-- Header --> 152 + <header class="border-b border-outline bg-surface px-6 py-4"> 153 + <div class="flex items-center justify-between"> 154 + <div> 155 + <h1 class="font-serif text-xl">bsky-browser</h1> 156 + <p class="font-mono text-xs text-muted">@{authInfo?.handle}</p> 157 + </div> 71 158 72 - <div class="bg-surface border border-outline rounded-lg p-6"> 73 - <div class="space-y-4"> 74 - <div> 75 - <label for="handle" class="block font-sans text-sm text-muted mb-2"> Bluesky Handle </label> 76 - <input 77 - id="handle" 78 - type="text" 79 - placeholder="username.bsky.social" 80 - bind:value={handle} 81 - onkeydown={handleKeydown} 82 - disabled={isLoading} 83 - 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" /> 159 + <div class="flex items-center gap-3"> 160 + <div class="flex items-center gap-2"> 161 + <label for="refreshLimit" class="font-sans text-xs text-muted">Limit:</label> 162 + <input 163 + id="refreshLimit" 164 + type="number" 165 + min="0" 166 + bind:value={refreshLimit} 167 + 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" /> 169 + </div> 170 + 171 + <button 172 + onclick={handleRefresh} 173 + 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"> 175 + {#if isIndexing} 176 + <span class="animate-pulse">Refreshing...</span> 177 + {:else} 178 + Refresh 179 + {/if} 180 + </button> 84 181 </div> 182 + </div> 183 + </header> 85 184 86 - <button 87 - onclick={handleLogin} 88 - disabled={isLoading || !handle.trim()} 89 - 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"> 90 - {#if isLoading} 91 - <span class="animate-pulse">Authenticating...</span> 92 - {:else} 93 - Login with Bluesky 94 - {/if} 95 - </button> 185 + <!-- 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> 96 190 </div> 191 + </div> 97 192 98 - {#if status} 99 - <div class="mt-4 p-3 bg-black border border-outline rounded"> 100 - <p class="font-mono text-xs text-muted">{status}</p> 193 + <!-- Progress Bar (bottom pinned) --> 194 + {#if showProgress} 195 + <div class="border-t border-outline bg-surface px-6 py-3"> 196 + <div class="flex items-center justify-between mb-2"> 197 + <span class="font-sans text-sm text-muted"> 198 + {isIndexing ? "Indexing..." : "Indexing complete"} 199 + </span> 200 + <span class="font-mono text-xs text-muted"> 201 + {indexStats.inserted} inserted / {indexStats.fetched} fetched 202 + {#if indexStats.errors > 0} 203 + <span class="text-red-500">({indexStats.errors} errors)</span> 204 + {/if} 205 + </span> 101 206 </div> 102 - {/if} 103 - </div> 104 - {:else} 105 - <div class="text-center"> 106 - <h1 class="font-serif text-3xl mb-4">Welcome!</h1> 107 - <div class="bg-surface border border-outline rounded-lg p-6"> 108 - <p class="font-sans text-[#e5e5e5] mb-2"> 109 - Logged in as <span class="font-mono text-muted">@{authInfo?.handle}</span> 110 - </p> 111 - <p class="font-mono text-xs text-[#333] truncate" title={authInfo?.did}> 112 - {authInfo?.did} 113 - </p> 207 + 208 + <div class="w-full h-1 bg-black rounded-full overflow-hidden"> 209 + <div 210 + class="h-full bg-[#333] transition-all duration-300 ease-out" 211 + style="width: {indexStats.fetched > 0 ? (indexStats.inserted / indexStats.fetched) * 100 : 0}%"> 212 + </div> 213 + </div> 114 214 </div> 115 - <p class="font-sans text-sm text-muted mt-8">Search functionality coming soon...</p> 116 - </div> 117 - {/if} 118 - </div> 215 + {/if} 216 + </div> 217 + {/if} 119 218 </main>
+4
frontend/src/index.css
··· 1 1 @import "tailwindcss"; 2 + @plugin "@egoist/tailwindcss-icons"; 2 3 3 4 @theme { 4 5 --font-mono: "JetBrains Mono Variable", monospace; ··· 7 8 --color-surface: #0a0a0a; 8 9 --color-outline: #1a1a1a; 9 10 --color-muted: #737373; 11 + --color-primary: #33b1ff; 12 + --color-primary-bright: #0f62fe; 13 + --color-secondary: #ee5396; 10 14 } 11 15 12 16 html {
+9
frontend/wailsjs/go/main/IndexService.d.ts
··· 1 + // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 2 + // This file is automatically generated. DO NOT EDIT 3 + import {context} from '../models'; 4 + 5 + export function IsIndexing():Promise<boolean>; 6 + 7 + export function Refresh(arg1:number):Promise<void>; 8 + 9 + export function SetContext(arg1:context.Context):Promise<void>;
+15
frontend/wailsjs/go/main/IndexService.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 IsIndexing() { 6 + return window['go']['main']['IndexService']['IsIndexing'](); 7 + } 8 + 9 + export function Refresh(arg1) { 10 + return window['go']['main']['IndexService']['Refresh'](arg1); 11 + } 12 + 13 + export function SetContext(arg1) { 14 + return window['go']['main']['IndexService']['SetContext'](arg1); 15 + }
frontend/wailsjs/runtime/package.json
frontend/wailsjs/runtime/runtime.d.ts
frontend/wailsjs/runtime/runtime.js
+12
go.mod
··· 21 21 github.com/google/uuid v1.6.0 // indirect 22 22 github.com/gorilla/websocket v1.5.3 // indirect 23 23 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 24 + github.com/ipfs/go-cid v0.4.1 // indirect 24 25 github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect 26 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 25 27 github.com/labstack/echo/v4 v4.13.3 // indirect 26 28 github.com/labstack/gommon v0.4.2 // indirect 27 29 github.com/leaanthony/go-ansi-parser v1.6.1 // indirect ··· 31 33 github.com/mattn/go-colorable v0.1.13 // indirect 32 34 github.com/mattn/go-isatty v0.0.20 // indirect 33 35 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 36 + github.com/minio/sha256-simd v1.0.1 // indirect 34 37 github.com/mr-tron/base58 v1.2.0 // indirect 38 + github.com/multiformats/go-base32 v0.1.0 // indirect 39 + github.com/multiformats/go-base36 v0.2.0 // indirect 40 + github.com/multiformats/go-multibase v0.2.0 // indirect 41 + github.com/multiformats/go-multihash v0.2.3 // indirect 42 + github.com/multiformats/go-varint v0.0.7 // indirect 35 43 github.com/ncruces/go-strftime v1.0.0 // indirect 36 44 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 37 45 github.com/pkg/errors v0.9.1 // indirect ··· 42 50 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 43 51 github.com/rivo/uniseg v0.4.7 // indirect 44 52 github.com/samber/lo v1.49.1 // indirect 53 + github.com/spaolacci/murmur3 v1.1.0 // indirect 45 54 github.com/tkrajina/go-reflector v0.5.8 // indirect 46 55 github.com/valyala/bytebufferpool v1.0.0 // indirect 47 56 github.com/valyala/fasttemplate v1.2.2 // indirect 48 57 github.com/wailsapp/go-webview2 v1.0.22 // indirect 49 58 github.com/wailsapp/mimetype v1.4.1 // indirect 59 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 50 60 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 51 61 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 52 62 golang.org/x/crypto v0.33.0 // indirect ··· 55 65 golang.org/x/sys v0.37.0 // indirect 56 66 golang.org/x/text v0.22.0 // indirect 57 67 golang.org/x/time v0.8.0 // indirect 68 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 58 69 google.golang.org/protobuf v1.33.0 // indirect 70 + lukechampine.com/blake3 v1.2.1 // indirect 59 71 modernc.org/libc v1.67.6 // indirect 60 72 modernc.org/mathutil v1.7.1 // indirect 61 73 modernc.org/memory v1.11.0 // indirect
+1
go.sum
··· 136 136 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 137 137 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 138 138 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 139 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 139 140 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 140 141 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 141 142 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+480
index_service.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "reflect" 8 + "sync" 9 + "sync/atomic" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/api/bsky" 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/wailsapp/wails/v2/pkg/runtime" 16 + ) 17 + 18 + // IndexService provides indexing functionality via Wails bindings 19 + type IndexService struct { 20 + ctx context.Context 21 + indexing atomic.Bool 22 + stats IndexStats 23 + statsMu sync.RWMutex 24 + } 25 + 26 + // IndexStats tracks indexing progress 27 + type IndexStats struct { 28 + Fetched int `json:"fetched"` 29 + Inserted int `json:"inserted"` 30 + Errors int `json:"errors"` 31 + Total int `json:"total"` 32 + } 33 + 34 + // IndexResult contains the final indexing result 35 + type IndexResult struct { 36 + Total int `json:"total"` 37 + Errors int `json:"errors"` 38 + Elapsed time.Duration `json:"elapsed"` 39 + } 40 + 41 + // PostResult carries either a Post or an error from fetching 42 + type PostResult struct { 43 + Post *Post 44 + Error error 45 + } 46 + 47 + // NewIndexService creates a new IndexService instance 48 + func NewIndexService() *IndexService { 49 + return &IndexService{} 50 + } 51 + 52 + // SetContext sets the Wails context for event emission 53 + func (s *IndexService) SetContext(ctx context.Context) { 54 + s.ctx = ctx 55 + } 56 + 57 + // IsIndexing returns true if indexing is currently in progress 58 + func (s *IndexService) IsIndexing() bool { 59 + return s.indexing.Load() 60 + } 61 + 62 + // Refresh fetches bookmarks and likes concurrently and indexes them 63 + func (s *IndexService) Refresh(limit int) error { 64 + if !s.indexing.CompareAndSwap(false, true) { 65 + return fmt.Errorf("indexing already in progress") 66 + } 67 + defer s.indexing.Store(false) 68 + 69 + start := time.Now() 70 + 71 + s.statsMu.Lock() 72 + s.stats = IndexStats{} 73 + s.statsMu.Unlock() 74 + 75 + s.emitEvent("index:started", map[string]any{}) 76 + 77 + client, err := s.createClient() 78 + if err != nil { 79 + s.emitEvent("index:done", IndexResult{Errors: 1, Elapsed: time.Since(start)}) 80 + return err 81 + } 82 + 83 + postCh := make(chan *PostResult, 100) 84 + batchSize := 10 85 + 86 + var wg sync.WaitGroup 87 + wg.Add(2) 88 + 89 + go func() { 90 + defer wg.Done() 91 + client.fetchBookmarks(limit, postCh, s) 92 + }() 93 + 94 + go func() { 95 + defer wg.Done() 96 + client.fetchLikes(limit, postCh, s) 97 + }() 98 + 99 + go func() { 100 + wg.Wait() 101 + close(postCh) 102 + }() 103 + 104 + successCount, errorCount := s.batchWriter(postCh, batchSize) 105 + 106 + result := IndexResult{ 107 + Total: successCount + errorCount, 108 + Errors: errorCount, 109 + Elapsed: time.Since(start), 110 + } 111 + 112 + s.emitEvent("index:done", result) 113 + return nil 114 + } 115 + 116 + // emitEvent emits a Wails event with the given name and data 117 + func (s *IndexService) emitEvent(name string, data any) { 118 + if s.ctx != nil { 119 + runtime.EventsEmit(s.ctx, name, data) 120 + } 121 + } 122 + 123 + // updateProgress updates stats and emits progress event 124 + func (s *IndexService) updateProgress(fetched, inserted, errors int) { 125 + s.statsMu.Lock() 126 + s.stats.Fetched += fetched 127 + s.stats.Inserted += inserted 128 + s.stats.Errors += errors 129 + stats := s.stats 130 + s.statsMu.Unlock() 131 + 132 + s.emitEvent("index:progress", stats) 133 + } 134 + 135 + // createClient creates an authenticated Bluesky client 136 + func (s *IndexService) createClient() (*BlueskyClient, error) { 137 + ctx := context.Background() 138 + 139 + auth, err := GetAuth() 140 + if err != nil { 141 + return nil, fmt.Errorf("failed to load auth: %w", err) 142 + } 143 + if auth == nil { 144 + return nil, fmt.Errorf("not authenticated") 145 + } 146 + 147 + if auth.SessionID == "" { 148 + return nil, fmt.Errorf("session not found") 149 + } 150 + 151 + did, err := syntax.ParseDID(auth.DID) 152 + if err != nil { 153 + return nil, fmt.Errorf("invalid DID: %w", err) 154 + } 155 + 156 + redirectURI := "http://127.0.0.1/callback" 157 + scopes := []string{"atproto", "transition:generic"} 158 + config := oauth.NewLocalhostConfig(redirectURI, scopes) 159 + store := oauth.NewMemStore() 160 + 161 + sessionData := oauth.ClientSessionData{ 162 + AccountDID: did, 163 + SessionID: auth.SessionID, 164 + HostURL: auth.PDSURL, 165 + AuthServerURL: auth.AuthServerURL, 166 + AuthServerTokenEndpoint: auth.AuthServerTokenEndpoint, 167 + AuthServerRevocationEndpoint: auth.AuthServerRevocationEndpoint, 168 + AccessToken: auth.AccessJWT, 169 + RefreshToken: auth.RefreshJWT, 170 + Scopes: scopes, 171 + DPoPAuthServerNonce: auth.DPoPAuthNonce, 172 + DPoPHostNonce: auth.DPoPHostNonce, 173 + DPoPPrivateKeyMultibase: auth.DPoPPrivateKey, 174 + } 175 + 176 + if err := store.SaveSession(ctx, sessionData); err != nil { 177 + return nil, fmt.Errorf("failed to save session: %w", err) 178 + } 179 + 180 + app := oauth.NewClientApp(&config, store) 181 + 182 + session, err := app.ResumeSession(ctx, did, auth.SessionID) 183 + if err != nil { 184 + return nil, fmt.Errorf("failed to resume session: %w", err) 185 + } 186 + 187 + return &BlueskyClient{ 188 + session: session, 189 + auth: auth, 190 + }, nil 191 + } 192 + 193 + // batchWriter reads from channel and inserts posts in batches 194 + func (s *IndexService) batchWriter(ch <-chan *PostResult, batchSize int) (int, int) { 195 + batch := make([]*Post, 0, batchSize) 196 + successCount := 0 197 + errorCount := 0 198 + 199 + flushBatch := func() { 200 + if len(batch) == 0 { 201 + return 202 + } 203 + 204 + for _, post := range batch { 205 + if err := InsertPost(post); err != nil { 206 + errorCount++ 207 + s.updateProgress(0, 0, 1) 208 + } else { 209 + successCount++ 210 + s.updateProgress(0, 1, 0) 211 + } 212 + } 213 + batch = batch[:0] 214 + } 215 + 216 + for result := range ch { 217 + if result.Error != nil { 218 + errorCount++ 219 + s.updateProgress(0, 0, 1) 220 + continue 221 + } 222 + 223 + if result.Post != nil { 224 + batch = append(batch, result.Post) 225 + s.updateProgress(1, 0, 0) 226 + 227 + if len(batch) >= batchSize { 228 + flushBatch() 229 + } 230 + } 231 + } 232 + 233 + flushBatch() 234 + return successCount, errorCount 235 + } 236 + 237 + // BlueskyClient wraps an authenticated OAuth session 238 + type BlueskyClient struct { 239 + session *oauth.ClientSession 240 + auth *Auth 241 + } 242 + 243 + // fetchBookmarks writes bookmarks to the provided channel in batches 244 + func (c *BlueskyClient) fetchBookmarks(maxPosts int, ch chan<- *PostResult, svc *IndexService) { 245 + ctx := context.Background() 246 + apiClient := c.session.APIClient() 247 + var cursor string 248 + batchSize := int64(100) 249 + count := 0 250 + 251 + for { 252 + resp, err := bsky.BookmarkGetBookmarks(ctx, apiClient, cursor, batchSize) 253 + if err != nil { 254 + ch <- &PostResult{Error: fmt.Errorf("failed to fetch bookmarks: %w", err)} 255 + return 256 + } 257 + 258 + for _, bookmark := range resp.Bookmarks { 259 + if bookmark.Item == nil { 260 + continue 261 + } 262 + 263 + if bookmark.Item.FeedDefs_PostView != nil { 264 + pv := bookmark.Item.FeedDefs_PostView 265 + 266 + exists, err := PostExists(pv.Uri) 267 + if err != nil { 268 + continue 269 + } 270 + if exists { 271 + continue 272 + } 273 + 274 + post := c.convertPostView(pv, "saved") 275 + if post != nil { 276 + ch <- &PostResult{Post: post} 277 + count++ 278 + 279 + if maxPosts > 0 && count >= maxPosts { 280 + return 281 + } 282 + } 283 + } 284 + } 285 + 286 + if resp.Cursor == nil || *resp.Cursor == "" { 287 + break 288 + } 289 + cursor = *resp.Cursor 290 + } 291 + } 292 + 293 + // fetchLikes writes likes to the provided channel in batches 294 + func (c *BlueskyClient) fetchLikes(maxPosts int, ch chan<- *PostResult, svc *IndexService) { 295 + ctx := context.Background() 296 + apiClient := c.session.APIClient() 297 + var cursor string 298 + batchSize := int64(100) 299 + count := 0 300 + 301 + for { 302 + resp, err := bsky.FeedGetActorLikes(ctx, apiClient, c.auth.DID, cursor, batchSize) 303 + if err != nil { 304 + ch <- &PostResult{Error: fmt.Errorf("failed to fetch likes: %w", err)} 305 + return 306 + } 307 + 308 + for _, feedView := range resp.Feed { 309 + if feedView.Post != nil { 310 + pv := feedView.Post 311 + 312 + exists, err := PostExists(pv.Uri) 313 + if err != nil { 314 + continue 315 + } 316 + if exists { 317 + continue 318 + } 319 + 320 + post := c.convertPostView(pv, "liked") 321 + if post != nil { 322 + ch <- &PostResult{Post: post} 323 + count++ 324 + 325 + if maxPosts > 0 && count >= maxPosts { 326 + return 327 + } 328 + } 329 + } 330 + } 331 + 332 + if resp.Cursor == nil || *resp.Cursor == "" { 333 + break 334 + } 335 + cursor = *resp.Cursor 336 + } 337 + } 338 + 339 + // convertPostView converts a FeedDefs_PostView to our Post struct 340 + func (c *BlueskyClient) convertPostView(pv *bsky.FeedDefs_PostView, source string) *Post { 341 + if pv == nil { 342 + return nil 343 + } 344 + 345 + record, facets, err := c.parsePostRecord(pv.Record) 346 + if err != nil { 347 + record = &postRecord{Text: "", CreatedAt: pv.IndexedAt} 348 + } 349 + 350 + var authorDID, authorHandle string 351 + if pv.Author != nil { 352 + authorDID = pv.Author.Did 353 + authorHandle = pv.Author.Handle 354 + } 355 + 356 + likeCount := 0 357 + if pv.LikeCount != nil { 358 + likeCount = int(*pv.LikeCount) 359 + } 360 + 361 + repostCount := 0 362 + if pv.RepostCount != nil { 363 + repostCount = int(*pv.RepostCount) 364 + } 365 + 366 + replyCount := 0 367 + if pv.ReplyCount != nil { 368 + replyCount = int(*pv.ReplyCount) 369 + } 370 + 371 + createdAt, err := syntax.ParseDatetimeLenient(record.CreatedAt) 372 + if err != nil { 373 + createdAt, _ = syntax.ParseDatetimeLenient(pv.IndexedAt) 374 + } 375 + 376 + return &Post{ 377 + URI: pv.Uri, 378 + CID: pv.Cid, 379 + AuthorDID: authorDID, 380 + AuthorHandle: authorHandle, 381 + Text: record.Text, 382 + CreatedAt: createdAt.Time(), 383 + LikeCount: likeCount, 384 + RepostCount: repostCount, 385 + ReplyCount: replyCount, 386 + Source: source, 387 + Facets: facets, 388 + } 389 + } 390 + 391 + // postRecord represents the expected structure of a post record 392 + type postRecord struct { 393 + Text string `json:"text"` 394 + CreatedAt string `json:"createdAt"` 395 + } 396 + 397 + // parsePostRecord extracts post data and facets from the LexiconTypeDecoder 398 + func (c *BlueskyClient) parsePostRecord(decoder interface{}) (*postRecord, string, error) { 399 + if decoder == nil { 400 + return &postRecord{Text: "", CreatedAt: ""}, "", nil 401 + } 402 + 403 + type lexDecoder struct{ Val any } 404 + 405 + d, ok := decoder.(*lexDecoder) 406 + if !ok { 407 + switch v := decoder.(type) { 408 + case *bsky.FeedPost: 409 + facets := c.extractFacets(v) 410 + return &postRecord{ 411 + Text: v.Text, 412 + CreatedAt: v.CreatedAt, 413 + }, facets, nil 414 + case bsky.FeedPost: 415 + facets := c.extractFacets(&v) 416 + return &postRecord{ 417 + Text: v.Text, 418 + CreatedAt: v.CreatedAt, 419 + }, facets, nil 420 + default: 421 + return c.parsePostRecordWithReflection(decoder) 422 + } 423 + } 424 + 425 + if d.Val == nil { 426 + return &postRecord{Text: "", CreatedAt: ""}, "", nil 427 + } 428 + 429 + if feedPost, ok := d.Val.(*bsky.FeedPost); ok { 430 + facets := c.extractFacets(feedPost) 431 + return &postRecord{ 432 + Text: feedPost.Text, 433 + CreatedAt: feedPost.CreatedAt, 434 + }, facets, nil 435 + } 436 + 437 + return &postRecord{Text: "", CreatedAt: ""}, "", fmt.Errorf("unknown record type: %T", d.Val) 438 + } 439 + 440 + // extractFacets extracts and serializes facets from a FeedPost 441 + func (c *BlueskyClient) extractFacets(feedPost *bsky.FeedPost) string { 442 + if feedPost == nil || len(feedPost.Facets) == 0 { 443 + return "" 444 + } 445 + 446 + facetsJSON, err := json.Marshal(feedPost.Facets) 447 + if err != nil { 448 + return "" 449 + } 450 + 451 + return string(facetsJSON) 452 + } 453 + 454 + // parsePostRecordWithReflection uses reflection to access the Val field 455 + func (c *BlueskyClient) parsePostRecordWithReflection(decoder any) (*postRecord, string, error) { 456 + val := reflect.ValueOf(decoder) 457 + if val.Kind() == reflect.Pointer { 458 + val = val.Elem() 459 + } 460 + 461 + valField := val.FieldByName("Val") 462 + if !valField.IsValid() { 463 + return &postRecord{Text: "", CreatedAt: ""}, "", fmt.Errorf("no Val field found") 464 + } 465 + 466 + actualVal := valField.Interface() 467 + if actualVal == nil { 468 + return &postRecord{Text: "", CreatedAt: ""}, "", nil 469 + } 470 + 471 + if feedPost, ok := actualVal.(*bsky.FeedPost); ok { 472 + facets := c.extractFacets(feedPost) 473 + return &postRecord{ 474 + Text: feedPost.Text, 475 + CreatedAt: feedPost.CreatedAt, 476 + }, facets, nil 477 + } 478 + 479 + return &postRecord{Text: "", CreatedAt: ""}, "", fmt.Errorf("unknown record type in Val: %T", actualVal) 480 + }
+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}, 25 + Bind: []any{app, app.authService, app.indexService}, 26 26 }) 27 27 28 28 if err != nil {