diff --git a/TODO.md b/TODO.md index df71a29..29cb239 100644 --- a/TODO.md +++ b/TODO.md @@ -29,13 +29,13 @@ ## Milestone 4 — Indexing & Progress -- [ ] Implement `IndexService` struct with Wails service binding -- [ ] `Refresh(limit)` — concurrent bookmark + like fetch, batch insert (port `RefreshAndIndex` logic) -- [ ] Populate `facets` column from `FeedPost.Facets` during `convertPostView` -- [ ] `IsIndexing()` — thread-safe boolean guard to prevent concurrent refreshes -- [ ] Emit Wails events: `index:started`, `index:progress`, `index:done` -- [ ] Frontend: "Refresh" button in header, optional limit input -- [ ] Frontend: bottom-pinned progress bar component driven by `index:*` events +- [x] Implement `IndexService` struct with Wails service binding +- [x] `Refresh(limit)` — concurrent bookmark + like fetch, batch insert (port `RefreshAndIndex` logic) +- [x] Populate `facets` column from `FeedPost.Facets` during `convertPostView` +- [x] `IsIndexing()` — thread-safe boolean guard to prevent concurrent refreshes +- [x] Emit Wails events: `index:started`, `index:progress`, `index:done` +- [x] Frontend: "Refresh" button in header, optional limit input +- [x] Frontend: bottom-pinned progress bar component driven by `index:*` events ## Milestone 5 — Search & Data Table diff --git a/app.go b/app.go index a1f98ab..12add69 100644 --- a/app.go +++ b/app.go @@ -5,29 +5,30 @@ import ( "fmt" "os" "path/filepath" - - "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct type App struct { - ctx context.Context - authService *AuthService + ctx context.Context + authService *AuthService + indexService *IndexService } // NewApp creates a new App application struct func NewApp() *App { return &App{ - authService: NewAuthService(), + authService: NewAuthService(), + indexService: NewIndexService(), } } -// startup is called when the app starts. -// -// The context is saved so we can call the runtime methods +// startup is called when the app starts. The context is saved so we can call +// the runtime methods. func (a *App) startup(ctx context.Context) { a.ctx = ctx + a.indexService.SetContext(ctx) + dbPath := getDBPath() if err := Open(dbPath); err != nil { fmt.Printf("failed to open database: %v\n", err) @@ -43,9 +44,8 @@ func (a *App) startup(ctx context.Context) { // shutdown is called when the app shuts down func (a *App) shutdown(ctx context.Context) { - runtime.LogInfo(ctx, "Shutting down") if err := Close(); err != nil { - runtime.LogErrorf(ctx, "failed to close database: %v", err) + fmt.Printf("failed to close database: %v\n", err) } } diff --git a/frontend/package.json b/frontend/package.json index 3a75efa..1015790 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { + "@egoist/tailwindcss-icons": "^1.9.2", + "@iconify-json/ri": "^1.2.10", "@sveltejs/vite-plugin-svelte": "^6.2.4", "svelte": "^5.53.12", "svelte-check": "^4.4.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d01432d..5803938 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: ^4.2.1 version: 4.2.1 devDependencies: + '@egoist/tailwindcss-icons': + specifier: ^1.9.2 + version: 1.9.2(tailwindcss@4.2.1) + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 version: 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) @@ -45,6 +51,14 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@egoist/tailwindcss-icons@1.9.2': + resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==} + peerDependencies: + tailwindcss: '*' + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -210,6 +224,15 @@ packages: '@fontsource-variable/lora@5.2.8': resolution: {integrity: sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg==} + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -492,6 +515,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -618,6 +644,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -630,6 +659,12 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -637,6 +672,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -677,6 +715,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -689,6 +731,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -742,6 +787,16 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.4 + + '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.2.1)': + dependencies: + '@iconify/utils': 3.1.0 + tailwindcss: 4.2.1 + '@esbuild/aix-ppc64@0.27.4': optional: true @@ -826,6 +881,18 @@ snapshots: '@fontsource-variable/lora@5.2.8': {} + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1027,6 +1094,8 @@ snapshots: clsx@2.1.1: {} + confbox@0.1.8: {} + deepmerge@4.3.1: {} detect-libc@2.1.2: {} @@ -1144,16 +1213,33 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mri@1.2.0: {} nanoid@3.3.11: {} obug@2.1.1: {} + package-manager-detector@1.6.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -1234,6 +1320,8 @@ snapshots: tapable@2.3.0: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -1243,6 +1331,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.4 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..efc037a --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c0abd98..b71be77 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,17 +4,47 @@ import "@fontsource-variable/lora"; import { onMount } from "svelte"; import { Login, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; + import { Refresh, IsIndexing } from "../wailsjs/go/main/IndexService"; + import { EventsOn } from "../wailsjs/runtime/runtime"; type AuthInfo = { handle: string; did: string }; + type IndexStats = { fetched: number; inserted: number; errors: number; total: number }; let handle = $state(""); let isLoading = $state(false); let status = $state(""); let isLoggedIn = $state(false); let authInfo = $state(null); + let isIndexing = $state(false); + let refreshLimit = $state(0); + let indexStats = $state({ fetched: 0, inserted: 0, errors: 0, total: 0 }); + let showProgress = $state(false); onMount(async () => { await checkAuthStatus(); + + EventsOn("index:started", () => { + isIndexing = true; + showProgress = true; + indexStats = { fetched: 0, inserted: 0, errors: 0, total: 0 }; + }); + + EventsOn("index:progress", (stats: any) => { + indexStats = stats; + }); + + EventsOn("index:done", (result: any) => { + isIndexing = false; + indexStats.total = result.total || 0; + setTimeout(() => { + showProgress = false; + }, 3000); + }); + + isIndexing = await IsIndexing(); + if (isIndexing) { + showProgress = true; + } }); async function checkAuthStatus() { @@ -54,6 +84,16 @@ } } + async function handleRefresh() { + if (isIndexing) return; + + try { + await Refresh(refreshLimit); + } catch (err) { + status = `Refresh failed: ${err}`; + } + } + function handleKeydown(event: KeyboardEvent) { if (event.key === "Enter" && !isLoading) { handleLogin(); @@ -61,59 +101,118 @@ } -
-
- {#if !isLoggedIn} -
-

bsky-browser

-

Search your Bluesky bookmarks and likes

-
+
+ {#if !isLoggedIn} + +
+
+
+

bsky-browser

+

Search your Bluesky bookmarks and likes

+
-
-
-
- - +
+
+
+ + +
+ +
- + {#if status} +
+

{status}

+
+ {/if}
+
+
+ {:else} + +
+ +
+
+
+

bsky-browser

+

@{authInfo?.handle}

+
- {#if status} -
-

{status}

+
+
+ + +
+ +
- {/if} -
- {:else} -
-

Welcome!

-
-

- Logged in as @{authInfo?.handle} -

-

- {authInfo?.did} -

-

Search functionality coming soon...

+
+ + +
+
+

Search functionality coming in the next milestone...

+

Use the Refresh button to fetch your bookmarks and likes

+
- {/if} -
+ + + {#if showProgress} +
+
+ + {isIndexing ? "Indexing..." : "Indexing complete"} + + + {indexStats.inserted} inserted / {indexStats.fetched} fetched + {#if indexStats.errors > 0} + ({indexStats.errors} errors) + {/if} + +
+ +
+
+
+
+
+ {/if} +
+ {/if}
diff --git a/frontend/src/index.css b/frontend/src/index.css index 2c8e775..3ba23f4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "@egoist/tailwindcss-icons"; @theme { --font-mono: "JetBrains Mono Variable", monospace; @@ -7,6 +8,9 @@ --color-surface: #0a0a0a; --color-outline: #1a1a1a; --color-muted: #737373; + --color-primary: #33b1ff; + --color-primary-bright: #0f62fe; + --color-secondary: #ee5396; } html { diff --git a/frontend/wailsjs/go/main/IndexService.d.ts b/frontend/wailsjs/go/main/IndexService.d.ts new file mode 100755 index 0000000..8b96768 --- /dev/null +++ b/frontend/wailsjs/go/main/IndexService.d.ts @@ -0,0 +1,9 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {context} from '../models'; + +export function IsIndexing():Promise; + +export function Refresh(arg1:number):Promise; + +export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/main/IndexService.js b/frontend/wailsjs/go/main/IndexService.js new file mode 100755 index 0000000..0d18b54 --- /dev/null +++ b/frontend/wailsjs/go/main/IndexService.js @@ -0,0 +1,15 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function IsIndexing() { + return window['go']['main']['IndexService']['IsIndexing'](); +} + +export function Refresh(arg1) { + return window['go']['main']['IndexService']['Refresh'](arg1); +} + +export function SetContext(arg1) { + return window['go']['main']['IndexService']['SetContext'](arg1); +} diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100644 new mode 100755 diff --git a/go.mod b/go.mod index ecf48b4..7aa47bc 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,9 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect @@ -31,7 +33,13 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -42,11 +50,13 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect golang.org/x/crypto v0.33.0 // indirect @@ -55,7 +65,9 @@ require ( golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.8.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.33.0 // indirect + lukechampine.com/blake3 v1.2.1 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 778d631..88ef2ee 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/index_service.go b/index_service.go new file mode 100644 index 0000000..db7bf2a --- /dev/null +++ b/index_service.go @@ -0,0 +1,480 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "sync" + "sync/atomic" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// IndexService provides indexing functionality via Wails bindings +type IndexService struct { + ctx context.Context + indexing atomic.Bool + stats IndexStats + statsMu sync.RWMutex +} + +// IndexStats tracks indexing progress +type IndexStats struct { + Fetched int `json:"fetched"` + Inserted int `json:"inserted"` + Errors int `json:"errors"` + Total int `json:"total"` +} + +// IndexResult contains the final indexing result +type IndexResult struct { + Total int `json:"total"` + Errors int `json:"errors"` + Elapsed time.Duration `json:"elapsed"` +} + +// PostResult carries either a Post or an error from fetching +type PostResult struct { + Post *Post + Error error +} + +// NewIndexService creates a new IndexService instance +func NewIndexService() *IndexService { + return &IndexService{} +} + +// SetContext sets the Wails context for event emission +func (s *IndexService) SetContext(ctx context.Context) { + s.ctx = ctx +} + +// IsIndexing returns true if indexing is currently in progress +func (s *IndexService) IsIndexing() bool { + return s.indexing.Load() +} + +// Refresh fetches bookmarks and likes concurrently and indexes them +func (s *IndexService) Refresh(limit int) error { + if !s.indexing.CompareAndSwap(false, true) { + return fmt.Errorf("indexing already in progress") + } + defer s.indexing.Store(false) + + start := time.Now() + + s.statsMu.Lock() + s.stats = IndexStats{} + s.statsMu.Unlock() + + s.emitEvent("index:started", map[string]any{}) + + client, err := s.createClient() + if err != nil { + s.emitEvent("index:done", IndexResult{Errors: 1, Elapsed: time.Since(start)}) + return err + } + + postCh := make(chan *PostResult, 100) + batchSize := 10 + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + client.fetchBookmarks(limit, postCh, s) + }() + + go func() { + defer wg.Done() + client.fetchLikes(limit, postCh, s) + }() + + go func() { + wg.Wait() + close(postCh) + }() + + successCount, errorCount := s.batchWriter(postCh, batchSize) + + result := IndexResult{ + Total: successCount + errorCount, + Errors: errorCount, + Elapsed: time.Since(start), + } + + s.emitEvent("index:done", result) + return nil +} + +// emitEvent emits a Wails event with the given name and data +func (s *IndexService) emitEvent(name string, data any) { + if s.ctx != nil { + runtime.EventsEmit(s.ctx, name, data) + } +} + +// updateProgress updates stats and emits progress event +func (s *IndexService) updateProgress(fetched, inserted, errors int) { + s.statsMu.Lock() + s.stats.Fetched += fetched + s.stats.Inserted += inserted + s.stats.Errors += errors + stats := s.stats + s.statsMu.Unlock() + + s.emitEvent("index:progress", stats) +} + +// createClient creates an authenticated Bluesky client +func (s *IndexService) createClient() (*BlueskyClient, error) { + ctx := context.Background() + + auth, err := GetAuth() + if err != nil { + return nil, fmt.Errorf("failed to load auth: %w", err) + } + if auth == nil { + return nil, fmt.Errorf("not authenticated") + } + + if auth.SessionID == "" { + return nil, fmt.Errorf("session not found") + } + + did, err := syntax.ParseDID(auth.DID) + if err != nil { + return nil, fmt.Errorf("invalid DID: %w", err) + } + + redirectURI := "http://127.0.0.1/callback" + scopes := []string{"atproto", "transition:generic"} + config := oauth.NewLocalhostConfig(redirectURI, scopes) + store := oauth.NewMemStore() + + sessionData := oauth.ClientSessionData{ + AccountDID: did, + SessionID: auth.SessionID, + HostURL: auth.PDSURL, + AuthServerURL: auth.AuthServerURL, + AuthServerTokenEndpoint: auth.AuthServerTokenEndpoint, + AuthServerRevocationEndpoint: auth.AuthServerRevocationEndpoint, + AccessToken: auth.AccessJWT, + RefreshToken: auth.RefreshJWT, + Scopes: scopes, + DPoPAuthServerNonce: auth.DPoPAuthNonce, + DPoPHostNonce: auth.DPoPHostNonce, + DPoPPrivateKeyMultibase: auth.DPoPPrivateKey, + } + + if err := store.SaveSession(ctx, sessionData); err != nil { + return nil, fmt.Errorf("failed to save session: %w", err) + } + + app := oauth.NewClientApp(&config, store) + + session, err := app.ResumeSession(ctx, did, auth.SessionID) + if err != nil { + return nil, fmt.Errorf("failed to resume session: %w", err) + } + + return &BlueskyClient{ + session: session, + auth: auth, + }, nil +} + +// batchWriter reads from channel and inserts posts in batches +func (s *IndexService) batchWriter(ch <-chan *PostResult, batchSize int) (int, int) { + batch := make([]*Post, 0, batchSize) + successCount := 0 + errorCount := 0 + + flushBatch := func() { + if len(batch) == 0 { + return + } + + for _, post := range batch { + if err := InsertPost(post); err != nil { + errorCount++ + s.updateProgress(0, 0, 1) + } else { + successCount++ + s.updateProgress(0, 1, 0) + } + } + batch = batch[:0] + } + + for result := range ch { + if result.Error != nil { + errorCount++ + s.updateProgress(0, 0, 1) + continue + } + + if result.Post != nil { + batch = append(batch, result.Post) + s.updateProgress(1, 0, 0) + + if len(batch) >= batchSize { + flushBatch() + } + } + } + + flushBatch() + return successCount, errorCount +} + +// BlueskyClient wraps an authenticated OAuth session +type BlueskyClient struct { + session *oauth.ClientSession + auth *Auth +} + +// fetchBookmarks writes bookmarks to the provided channel in batches +func (c *BlueskyClient) fetchBookmarks(maxPosts int, ch chan<- *PostResult, svc *IndexService) { + ctx := context.Background() + apiClient := c.session.APIClient() + var cursor string + batchSize := int64(100) + count := 0 + + for { + resp, err := bsky.BookmarkGetBookmarks(ctx, apiClient, cursor, batchSize) + if err != nil { + ch <- &PostResult{Error: fmt.Errorf("failed to fetch bookmarks: %w", err)} + return + } + + for _, bookmark := range resp.Bookmarks { + if bookmark.Item == nil { + continue + } + + if bookmark.Item.FeedDefs_PostView != nil { + pv := bookmark.Item.FeedDefs_PostView + + exists, err := PostExists(pv.Uri) + if err != nil { + continue + } + if exists { + continue + } + + post := c.convertPostView(pv, "saved") + if post != nil { + ch <- &PostResult{Post: post} + count++ + + if maxPosts > 0 && count >= maxPosts { + return + } + } + } + } + + if resp.Cursor == nil || *resp.Cursor == "" { + break + } + cursor = *resp.Cursor + } +} + +// fetchLikes writes likes to the provided channel in batches +func (c *BlueskyClient) fetchLikes(maxPosts int, ch chan<- *PostResult, svc *IndexService) { + ctx := context.Background() + apiClient := c.session.APIClient() + var cursor string + batchSize := int64(100) + count := 0 + + for { + resp, err := bsky.FeedGetActorLikes(ctx, apiClient, c.auth.DID, cursor, batchSize) + if err != nil { + ch <- &PostResult{Error: fmt.Errorf("failed to fetch likes: %w", err)} + return + } + + for _, feedView := range resp.Feed { + if feedView.Post != nil { + pv := feedView.Post + + exists, err := PostExists(pv.Uri) + if err != nil { + continue + } + if exists { + continue + } + + post := c.convertPostView(pv, "liked") + if post != nil { + ch <- &PostResult{Post: post} + count++ + + if maxPosts > 0 && count >= maxPosts { + return + } + } + } + } + + if resp.Cursor == nil || *resp.Cursor == "" { + break + } + cursor = *resp.Cursor + } +} + +// convertPostView converts a FeedDefs_PostView to our Post struct +func (c *BlueskyClient) convertPostView(pv *bsky.FeedDefs_PostView, source string) *Post { + if pv == nil { + return nil + } + + record, facets, err := c.parsePostRecord(pv.Record) + if err != nil { + record = &postRecord{Text: "", CreatedAt: pv.IndexedAt} + } + + var authorDID, authorHandle string + if pv.Author != nil { + authorDID = pv.Author.Did + authorHandle = pv.Author.Handle + } + + likeCount := 0 + if pv.LikeCount != nil { + likeCount = int(*pv.LikeCount) + } + + repostCount := 0 + if pv.RepostCount != nil { + repostCount = int(*pv.RepostCount) + } + + replyCount := 0 + if pv.ReplyCount != nil { + replyCount = int(*pv.ReplyCount) + } + + createdAt, err := syntax.ParseDatetimeLenient(record.CreatedAt) + if err != nil { + createdAt, _ = syntax.ParseDatetimeLenient(pv.IndexedAt) + } + + return &Post{ + URI: pv.Uri, + CID: pv.Cid, + AuthorDID: authorDID, + AuthorHandle: authorHandle, + Text: record.Text, + CreatedAt: createdAt.Time(), + LikeCount: likeCount, + RepostCount: repostCount, + ReplyCount: replyCount, + Source: source, + Facets: facets, + } +} + +// postRecord represents the expected structure of a post record +type postRecord struct { + Text string `json:"text"` + CreatedAt string `json:"createdAt"` +} + +// parsePostRecord extracts post data and facets from the LexiconTypeDecoder +func (c *BlueskyClient) parsePostRecord(decoder interface{}) (*postRecord, string, error) { + if decoder == nil { + return &postRecord{Text: "", CreatedAt: ""}, "", nil + } + + type lexDecoder struct{ Val any } + + d, ok := decoder.(*lexDecoder) + if !ok { + switch v := decoder.(type) { + case *bsky.FeedPost: + facets := c.extractFacets(v) + return &postRecord{ + Text: v.Text, + CreatedAt: v.CreatedAt, + }, facets, nil + case bsky.FeedPost: + facets := c.extractFacets(&v) + return &postRecord{ + Text: v.Text, + CreatedAt: v.CreatedAt, + }, facets, nil + default: + return c.parsePostRecordWithReflection(decoder) + } + } + + if d.Val == nil { + return &postRecord{Text: "", CreatedAt: ""}, "", nil + } + + if feedPost, ok := d.Val.(*bsky.FeedPost); ok { + facets := c.extractFacets(feedPost) + return &postRecord{ + Text: feedPost.Text, + CreatedAt: feedPost.CreatedAt, + }, facets, nil + } + + return &postRecord{Text: "", CreatedAt: ""}, "", fmt.Errorf("unknown record type: %T", d.Val) +} + +// extractFacets extracts and serializes facets from a FeedPost +func (c *BlueskyClient) extractFacets(feedPost *bsky.FeedPost) string { + if feedPost == nil || len(feedPost.Facets) == 0 { + return "" + } + + facetsJSON, err := json.Marshal(feedPost.Facets) + if err != nil { + return "" + } + + return string(facetsJSON) +} + +// parsePostRecordWithReflection uses reflection to access the Val field +func (c *BlueskyClient) parsePostRecordWithReflection(decoder any) (*postRecord, string, error) { + val := reflect.ValueOf(decoder) + if val.Kind() == reflect.Pointer { + val = val.Elem() + } + + valField := val.FieldByName("Val") + if !valField.IsValid() { + return &postRecord{Text: "", CreatedAt: ""}, "", fmt.Errorf("no Val field found") + } + + actualVal := valField.Interface() + if actualVal == nil { + return &postRecord{Text: "", CreatedAt: ""}, "", nil + } + + if feedPost, ok := actualVal.(*bsky.FeedPost); ok { + facets := c.extractFacets(feedPost) + return &postRecord{ + Text: feedPost.Text, + CreatedAt: feedPost.CreatedAt, + }, facets, nil + } + + return &postRecord{Text: "", CreatedAt: ""}, "", fmt.Errorf("unknown record type in Val: %T", actualVal) +} diff --git a/main.go b/main.go index ab38f89..155823c 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ func main() { BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 1}, OnStartup: app.startup, OnShutdown: app.shutdown, - Bind: []any{app, app.authService}, + Bind: []any{app, app.authService, app.indexService}, }) if err != nil {