feat(storygraph): add StoryGraph sync integration (#568)

* feat(storygraph): add StoryGraph sync integration

Sync reading status and progress from BookOrbit to StoryGraph using
session-cookie authentication. Includes book matching with manual
rematch and edition selection, per-user sync toggles, a throttled
request queue, a storygraph_sync permission, and a StoryGraph
settings page.

Closes #274

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vi6e8ZJRAGMmRSmSj9sWXR

* fix(storygraph): address automated review findings

- clear stale cookie validation badge when inputs change
- reject saves with only one cookie value instead of silently dropping it
- handle linked-books load failures with a retryable error state so post-action
  reloads cannot misattribute failures to the action
- add a 30s request timeout so a hung StoryGraph response cannot stall a user's
  sync queue indefinitely
- reserve throttle slots synchronously so concurrent requests serialize
- reject empty cookie strings that would overwrite stored credentials
- report the retry response status when the rereading fallback fails
- add component specs for the connection card and linked books, plus regression
  tests for the throttle, settings, and retry-status fixes

* test(storygraph): fully restore setTimeout spies between queue tests

* feat(storygraph): add sync-scope option

Adds the book sync scope selector (all eligible books vs selected books
only) and the per-book StoryGraph sync control on the book details page,
mirroring the Hardcover integration's controls. All books with a syncable
status are now eligible — the previous connected-at cutoff that skipped
books finished before connecting is removed in favor of explicit scope
and per-book selection.

* fix(storygraph): stream live sync progress and surface failed books

The manual sync progress stream emitted the same mutated status object,
so distinctUntilChanged dropped every update after the first and the bar
never moved. Progress snapshots are now immutable and track processed
books (synced + skipped + failed), so the bar advances for every book.

Adds live synced/skipped/failed counters, a persistent last-run summary
from a terminal completed/cancelled event, and a failed-books list under
Manual sync backed by GET /storygraph/sync/failures. Details-page sync
state now refetches when the global sync scope changes.

(cherry picked from commit 075eeaa0c8678d4ebbba5dcce7aa15df42d64f51)

* feat(storygraph): show distinguishing identifiers in the editions list

Edition rows previously showed only format, pages, and language, making
identical-looking editions impossible to tell apart. The editions parser
now also scrapes ISBN, publisher, publication date, and cover image when
present, and each row shows the edition title, those identifiers, a cover
thumbnail, and the StoryGraph edition id linking to the edition page for
verification before switching.

(cherry picked from commit 09ec111613e9be149b556f64eb242cd3d82b296c)

* fix(storygraph): auto-refresh linked books after sync and add unmatched hint

The Linked books list only loaded on mount, so a book linked during a manual
sync run stayed on 'Not linked yet' until a manual page refresh. It now
silently reloads when a run completes (no spinner flash, keeps expanded rows),
guarded by a request id so overlapping reloads can't resolve out of order.
Books StoryGraph couldn't match automatically now show a short hint pointing
at 'Try auto-match' or pasting the StoryGraph URL.

(cherry picked from commit 1d0cfa1e06f3e211258620f7ad5e61373df7a310)

* feat(storygraph): compact edition link and wrap identifiers on mobile

The editions list showed the raw StoryGraph edition UUID as a long
monospace hyperlink, which was hard to read and cluttered the row. Replace
it with a small icon-only 'Open on StoryGraph' button (aria-labelled) next
to 'Use this', and let the publisher/date/ISBN line wrap instead of
truncating so the ISBN is no longer cut off on narrow/mobile screens.

(cherry picked from commit cdf4d64063534adeea9daafad11cd58a0cbc9a00)

* fix(storygraph): re-sync books re-added after being marked unread, clarify labels

A book synced while 'read' then marked 'unread' left its synced markers in
place, so re-marking it read compared against a stale lastSyncedStatus and was
silently skipped (shown as Linked but never re-pushed) until 'Try auto-match'.
On the unread transition the synced markers are now cleared while keeping the
match, so re-adding the book re-syncs.

Also present match methods with real casing (ISBN/Title/Cached/Manual) and
clarify that the manual-link field takes a StoryGraph URL or book ID (the code
after /books/), not an ISBN.

(cherry picked from commit 771e7e4fe793afbc30800717ae1c017ca307e5e1)

* refactor(storygraph): compare skippedBooks explicitly in sync-status dedup

The active-sync stream's equality check omitted skippedBooks, relying on
processedBooks (synced+skipped+failed) to capture skip-only changes. That is
correct but non-obvious; comparing every counter explicitly makes the intent
clear. No behavior change.

(cherry picked from commit f267088939f0d2493caadab8eb90f24ab4b28c0b)

* fix(storygraph): encode manually-entered book id in the lookup path

The manual-link id can be pasted directly, so a stray ?, #, or / would change
which /books/ URL is requested. Encode it before interpolating; a bad id now
404s cleanly instead of being stored.

* style(storygraph): fix prettier formatting and remove em dashes

The CI Lint (Client) job runs prettier --check, which flagged
StorygraphLinkedBooks.vue and StorygraphSyncProgress.vue. Reflow both
to prettier style and replace em dashes in UI strings and comments with
regular punctuation per project convention.

* fix(storygraph): harden sync access and batching

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
blinkidy
2026-07-08 16:15:17 -07:00
committed by GitHub
parent 36bfc6e83d
commit ec4e5e2fc4
57 changed files with 34185 additions and 6 deletions
@@ -43,6 +43,7 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
Permission.KoreaderSync,
Permission.HardcoverSync,
Permission.ReadwiseSync,
Permission.StorygraphSync,
Permission.OpdsAccess,
Permission.BookDockAccess,
],
@@ -116,6 +117,7 @@ function applyPreset(preset: 'standard' | 'admin' | 'clear') {
Permission.KoreaderSync,
Permission.HardcoverSync,
Permission.ReadwiseSync,
Permission.StorygraphSync,
Permission.OpdsAccess,
Permission.BookDockAccess,
])
@@ -53,6 +53,7 @@ import { formatCommunityRatingValue } from '@/features/book/lib/community-rating
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import HardcoverBookSyncGridItem from '@/features/hardcover/components/HardcoverBookSyncGridItem.vue'
import StorygraphBookSyncGridItem from '@/features/storygraph/components/StorygraphBookSyncGridItem.vue'
type FileProgress = {
percentage: number
@@ -1918,6 +1919,7 @@ watch(
<dd v-else class="text-sm text-foreground mt-0.5">-</dd>
</div>
<HardcoverBookSyncGridItem :book-id="book.id" />
<StorygraphBookSyncGridItem :book-id="book.id" />
<div class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Date Started</dt>
<template v-if="isEditingReadingDate('startedAt')">
@@ -287,13 +287,17 @@ describe('DetailsTab cover surface', () => {
expect(tooltips.some((t) => t.includes('4.3 / 5') && t.includes('12,345'))).toBe(true)
})
it('places the Hardcover sync grid item with the current book id', async () => {
it('places the sync grid items with the current book id', async () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
const item = wrapper.findComponent({ name: 'HardcoverBookSyncGridItem' })
expect(item.exists()).toBe(true)
expect(item.props('bookId')).toBe(12)
const hardcoverItem = wrapper.findComponent({ name: 'HardcoverBookSyncGridItem' })
expect(hardcoverItem.exists()).toBe(true)
expect(hardcoverItem.props('bookId')).toBe(12)
const storygraphItem = wrapper.findComponent({ name: 'StorygraphBookSyncGridItem' })
expect(storygraphItem.exists()).toBe(true)
expect(storygraphItem.props('bookId')).toBe(12)
})
it('renders Send via Email button and opens dialog when user has email_send permission', async () => {
@@ -58,6 +58,10 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'Readwise', routeName: 'settings-readwise' })
}
if (su || perms.includes(Permission.StorygraphSync)) {
result.push({ label: 'StoryGraph', routeName: 'settings-storygraph' })
}
if (su || perms.includes('manage_users') || perms.includes('manage_app_settings')) {
result.push({ label: 'Admin', routeName: 'settings-admin' })
}
@@ -105,7 +105,14 @@ describe('SettingsHeader', () => {
it('places integration tabs after OPDS', () => {
const labels = getTabLabels(mountHeader({ su: true }))
expect(labels.slice(labels.indexOf('OPDS'), labels.indexOf('Admin'))).toEqual(['OPDS', 'Kobo', 'KOReader', 'Hardcover', 'Readwise'])
expect(labels.slice(labels.indexOf('OPDS'), labels.indexOf('Admin'))).toEqual([
'OPDS',
'Kobo',
'KOReader',
'Hardcover',
'Readwise',
'StoryGraph',
])
})
it('places Readwise tab after Hardcover', () => {
@@ -263,6 +270,12 @@ describe('SettingsHeader', () => {
expect(btn?.classes()).toContain('border-primary')
})
it('StoryGraph tab is active when route is settings-storygraph', () => {
const wrapper = mountHeader({ routeName: 'settings-storygraph', perms: ['storygraph_sync'] })
const btn = wrapper.findAll('button').find((b) => b.text() === 'StoryGraph')
expect(btn?.classes()).toContain('border-primary')
})
it('admin tab is active when route is settings-admin', () => {
const wrapper = mountHeader({ su: true, routeName: 'settings-admin' })
const btn = wrapper.findAll('button').find((b) => b.text() === 'Admin')
@@ -0,0 +1,171 @@
import { api } from '@/lib/api'
import type {
StorygraphActiveSyncStatus,
StorygraphBookSyncNowResult,
StorygraphBookSyncState,
StorygraphSyncFailure,
StorygraphSyncPendingSummary,
StorygraphSettings,
StorygraphCookieValidationResult,
StorygraphEdition,
StorygraphLinkedBook,
StorygraphLinkResult,
UpdateStorygraphBookSyncPayload,
UpsertStorygraphSettingsPayload,
} from '@bookorbit/types'
const BASE = '/api/v1/storygraph'
export async function fetchStorygraphSettings(): Promise<StorygraphSettings> {
const res = await api(`${BASE}/settings`)
if (!res.ok) throw new Error('Failed to fetch StoryGraph settings')
return res.json()
}
export async function upsertStorygraphSettings(payload: UpsertStorygraphSettingsPayload): Promise<StorygraphSettings> {
const res = await api(`${BASE}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { message?: string }).message ?? 'Failed to save settings')
}
return res.json()
}
export async function disconnectStorygraph(): Promise<void> {
const res = await api(`${BASE}/settings`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to disconnect StoryGraph')
}
export async function validateStorygraphCookies(sessionCookie?: string, rememberToken?: string): Promise<StorygraphCookieValidationResult> {
const res = await api(`${BASE}/validate-cookies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sessionCookie && rememberToken ? { sessionCookie, rememberToken } : {}),
})
if (!res.ok) throw new Error('Failed to validate cookies')
return res.json()
}
export async function startStorygraphSync(): Promise<{ runId: number }> {
const res = await api(`${BASE}/sync`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { message?: string }).message ?? 'Failed to start sync')
}
return res.json()
}
export async function cancelStorygraphSync(): Promise<void> {
await api(`${BASE}/sync`, { method: 'DELETE' })
}
export async function fetchStorygraphSyncStatus(): Promise<StorygraphActiveSyncStatus | null> {
const res = await api(`${BASE}/sync/status`)
if (!res.ok) return null
return res.json()
}
export async function streamStorygraphSyncStatus(onStatus: (status: StorygraphActiveSyncStatus | null) => void, signal?: AbortSignal): Promise<void> {
const res = await api(`${BASE}/sync/stream`, { signal })
if (!res.ok || !res.body) throw new Error('Failed to stream StoryGraph sync status')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
for (const event of events) {
const line = event.split('\n').find((entry) => entry.startsWith('data:'))
if (!line) continue
try {
const payload = JSON.parse(line.slice(5).trim()) as { activeSyncStatus: StorygraphActiveSyncStatus | null }
onStatus(payload.activeSyncStatus)
} catch {
// Ignore malformed SSE payloads.
}
}
}
}
export async function fetchStorygraphSyncFailures(): Promise<StorygraphSyncFailure[]> {
const res = await api(`${BASE}/sync/failures`)
if (!res.ok) throw new Error('Failed to fetch StoryGraph sync failures')
return res.json()
}
export async function fetchStorygraphSyncPendingSummary(): Promise<StorygraphSyncPendingSummary> {
const res = await api(`${BASE}/sync/pending`)
if (!res.ok) return { totalBooks: 0, pendingBooks: 0 }
return res.json()
}
export async function rematchStorygraphBook(bookId: number): Promise<{ result: 'synced' | 'skipped' | 'failed' }> {
const res = await api(`${BASE}/books/${bookId}/rematch`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to re-match with StoryGraph')
return res.json()
}
export async function fetchStorygraphBookSyncState(bookId: number): Promise<StorygraphBookSyncState> {
const res = await api(`${BASE}/books/${bookId}/sync-state`)
if (!res.ok) throw new Error('Failed to fetch StoryGraph sync state')
return res.json()
}
export async function updateStorygraphBookSyncState(bookId: number, payload: UpdateStorygraphBookSyncPayload): Promise<StorygraphBookSyncState> {
const res = await api(`${BASE}/books/${bookId}/sync-state`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('Failed to update StoryGraph sync state')
return res.json()
}
export async function startStorygraphBookSync(bookId: number): Promise<StorygraphBookSyncNowResult> {
const res = await api(`${BASE}/books/${bookId}/sync`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to sync StoryGraph book')
return res.json()
}
export async function fetchStorygraphLinkedBooks(): Promise<StorygraphLinkedBook[]> {
const res = await api(`${BASE}/books`)
if (!res.ok) return []
return res.json()
}
export async function linkStorygraphBook(bookId: number, input: string): Promise<StorygraphLinkResult> {
const res = await api(`${BASE}/books/${bookId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
})
if (!res.ok) throw new Error('Failed to link StoryGraph book')
return res.json()
}
export async function fetchStorygraphEditions(bookId: number): Promise<StorygraphEdition[]> {
const res = await api(`${BASE}/books/${bookId}/editions`)
if (!res.ok) return []
return res.json()
}
export async function setStorygraphEdition(bookId: number, editionId: string): Promise<{ success: boolean }> {
const res = await api(`${BASE}/books/${bookId}/edition`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editionId }),
})
if (!res.ok) throw new Error('Failed to set StoryGraph edition')
return res.json()
}
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RefreshCw } from '@lucide/vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useStorygraphBookSyncState } from '../composables/useStorygraphBookSyncState'
const props = defineProps<{ bookId: number }>()
const bookIdRef = computed(() => props.bookId)
const { visible, syncEnabled, canSyncNow, statusText, statusClass, disabled, syncNow, setSyncEnabled } = useStorygraphBookSyncState(bookIdRef)
</script>
<template>
<div v-if="visible" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">StoryGraph Sync</dt>
<dd class="mt-0.5 flex flex-wrap items-center gap-2">
<ToggleSwitch
:model-value="syncEnabled"
:disabled="disabled"
aria-label="Sync this book with StoryGraph"
@update:model-value="setSyncEnabled"
/>
<span class="min-w-0 truncate text-sm" :class="statusClass">{{ statusText }}</span>
<button
v-if="canSyncNow"
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors disabled:cursor-not-allowed disabled:opacity-40"
:disabled="disabled"
@click="syncNow"
>
<RefreshCw class="size-3.5" />
Sync now
</button>
</dd>
</div>
</template>
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { Link, Save, CheckCircle2, AlertCircle, Info, Loader2, Unlink } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useStorygraphSettings } from '../composables/useStorygraphSettings'
const { settings, saving, validating, error, fetchSettings, saveSettings, disconnect, validateCookies } = useStorygraphSettings()
const sessionCookieInput = ref('')
const rememberTokenInput = ref('')
const cookiesVisible = ref(false)
const validationResult = ref<{ valid: boolean } | null>(null)
const form = reactive({
enabled: true,
bookSyncMode: 'all_eligible' as 'all_eligible' | 'selected_only',
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
})
onMounted(async () => {
await fetchSettings()
if (settings.value) {
form.enabled = settings.value.enabled
form.bookSyncMode = settings.value.bookSyncMode
form.autoSyncOnStatusChange = settings.value.autoSyncOnStatusChange
form.autoSyncOnProgressUpdate = settings.value.autoSyncOnProgressUpdate
}
})
// A lingering result would misrepresent edited values, so any input change resets it.
watch([sessionCookieInput, rememberTokenInput], () => {
validationResult.value = null
})
async function handleValidateCookies() {
if (!sessionCookieInput.value.trim() || !rememberTokenInput.value.trim()) {
toast.error('Enter both cookie values first')
return
}
const result = await validateCookies(sessionCookieInput.value.trim(), rememberTokenInput.value.trim())
validationResult.value = result
}
async function handleSave() {
const hasNewCookies = sessionCookieInput.value.trim() && rememberTokenInput.value.trim()
if (!hasNewCookies && (sessionCookieInput.value.trim() || rememberTokenInput.value.trim())) {
toast.error('Enter both cookie values to update the connection')
return
}
const ok = await saveSettings({
...(hasNewCookies ? { sessionCookie: sessionCookieInput.value.trim(), rememberToken: rememberTokenInput.value.trim() } : {}),
enabled: form.enabled,
bookSyncMode: form.bookSyncMode,
autoSyncOnStatusChange: form.autoSyncOnStatusChange,
autoSyncOnProgressUpdate: form.autoSyncOnProgressUpdate,
})
if (ok) {
sessionCookieInput.value = ''
rememberTokenInput.value = ''
toast.success('StoryGraph settings saved')
} else {
toast.error(error.value ?? 'Failed to save settings')
}
}
async function handleDisconnect() {
await disconnect()
sessionCookieInput.value = ''
rememberTokenInput.value = ''
validationResult.value = null
toast.success('StoryGraph disconnected')
}
function toggleCookiesVisible() {
cookiesVisible.value = !cookiesVisible.value
}
const bookSyncModeOptions = [
{
id: 'all_eligible' as const,
label: 'All eligible books',
description: 'StoryGraph sync runs for every eligible book unless you exclude it on the book details page.',
},
{
id: 'selected_only' as const,
label: 'Selected books only',
description: 'StoryGraph sync only runs for books you turn on from the book details page.',
},
]
function selectBookSyncMode(mode: 'all_eligible' | 'selected_only') {
form.bookSyncMode = mode
}
</script>
<template>
<div class="border border-border rounded-lg bg-card px-4 py-4 md:px-5 md:py-5 shadow-xs space-y-5">
<div class="flex items-center gap-3">
<Link class="size-5 text-primary shrink-0" />
<div>
<p class="font-medium text-sm">Connection</p>
<p class="text-xs text-muted-foreground mt-0.5">Connect your StoryGraph account to sync reading data.</p>
</div>
<div v-if="settings?.cookiesConfigured" class="ml-auto flex items-center gap-1.5 text-xs text-green-600">
<CheckCircle2 class="size-3.5" />
Connected
</div>
</div>
<div class="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-2">
<Info class="size-3.5 shrink-0 text-amber-600 mt-0.5" />
<p class="text-xs text-amber-700 leading-relaxed">
StoryGraph has no public API. This integration works by reusing two cookies from your own logged-in browser session, the same approach used by
community KOReader plugins. It can break if StoryGraph changes their site, and you may occasionally need to re-paste fresh cookie values.
</p>
</div>
<div class="space-y-3">
<ol class="text-xs text-muted-foreground leading-relaxed list-decimal list-inside space-y-1">
<li>
Log in at
<a href="https://app.thestorygraph.com" target="_blank" rel="noopener" class="text-primary underline underline-offset-2"
>app.thestorygraph.com</a
>
in your browser.
</li>
<li>Open Developer Tools (F12) and go to Application/Storage &rarr; Cookies for that site.</li>
<li>
Copy the values of the <code class="px-1 rounded bg-muted">_storygraph_session</code> and
<code class="px-1 rounded bg-muted">remember_user_token</code> cookies below.
</li>
</ol>
<div class="space-y-2">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">_storygraph_session</label>
<div class="flex gap-2">
<input
v-model="sessionCookieInput"
:type="cookiesVisible ? 'text' : 'password'"
placeholder="Paste the _storygraph_session cookie value"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
autocomplete="off"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">remember_user_token</label>
<div class="flex gap-2">
<input
v-model="rememberTokenInput"
:type="cookiesVisible ? 'text' : 'password'"
placeholder="Paste the remember_user_token cookie value"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
autocomplete="off"
/>
<button
type="button"
class="px-3 py-2 text-xs rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
@click="toggleCookiesVisible"
>
{{ cookiesVisible ? 'Hide' : 'Show' }}
</button>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
:disabled="validating || !sessionCookieInput.trim() || !rememberTokenInput.trim()"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="handleValidateCookies"
>
<Loader2 v-if="validating" class="size-3 animate-spin" />
Validate cookies
</button>
<span
v-if="validationResult !== null"
class="flex items-center gap-1 text-xs"
:class="validationResult.valid ? 'text-green-600' : 'text-destructive'"
>
<CheckCircle2 v-if="validationResult.valid" class="size-3.5" />
<AlertCircle v-else class="size-3.5" />
{{ validationResult.valid ? 'Valid' : 'Invalid or expired cookies' }}
</span>
</div>
<div v-if="settings?.cookiesConfigured" class="space-y-3 pt-2 border-t border-border">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sync options</p>
<div class="flex items-start gap-2 rounded-md border border-border/70 bg-muted/40 px-2.5 py-2">
<Info class="size-3.5 shrink-0 text-muted-foreground mt-0.5" />
<p class="text-xs text-muted-foreground leading-relaxed">
Sync runs only for books that are not unread. This applies to status and progress triggers. These switches control when a sync runs.
</p>
</div>
<div class="space-y-2">
<div>
<p class="text-sm">Book sync scope</p>
<p class="text-xs text-muted-foreground mt-0.5">Choose whether StoryGraph sync starts broadly or only for books you pick.</p>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="option in bookSyncModeOptions"
:key="option.id"
type="button"
class="rounded-md border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!form.enabled"
:class="
form.bookSyncMode === option.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:bg-muted/50'
"
@click="selectBookSyncMode(option.id)"
>
<div class="text-sm font-medium">{{ option.label }}</div>
<div class="mt-0.5 text-xs leading-relaxed">{{ option.description }}</div>
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Enable sync</p>
<p class="text-xs text-muted-foreground mt-0.5">Pause all StoryGraph syncing without disconnecting.</p>
</div>
<ToggleSwitch v-model="form.enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Sync on status change</p>
<p class="text-xs text-muted-foreground mt-0.5">Push updates when you mark a book as reading, read, etc.</p>
</div>
<ToggleSwitch v-model="form.autoSyncOnStatusChange" :disabled="!form.enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Sync on progress update</p>
<p class="text-xs text-muted-foreground mt-0.5">Push reading progress when BookOrbit or KOReader progress changes.</p>
</div>
<ToggleSwitch v-model="form.autoSyncOnProgressUpdate" :disabled="!form.enabled" />
</div>
</div>
<div class="flex items-center justify-between pt-2 border-t border-border gap-2">
<button
v-if="settings?.cookiesConfigured"
type="button"
:disabled="saving"
class="flex items-center gap-1.5 text-xs text-destructive hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
@click="handleDisconnect"
>
<Unlink class="size-3.5" />
Disconnect
</button>
<div class="flex-1" />
<button
type="button"
:disabled="saving"
class="flex items-center gap-1.5 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="handleSave"
>
<Loader2 v-if="saving" class="size-3.5 animate-spin" />
<Save v-else class="size-3.5" />
Save
</button>
</div>
</div>
</template>
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, CheckCircle2, AlertCircle, ExternalLink, HelpCircle, Loader2, RefreshCw, Link2 } from '@lucide/vue'
import { toast } from 'vue-sonner'
import type { StorygraphEdition, StorygraphLinkedBook } from '@bookorbit/types'
import {
fetchStorygraphEditions,
fetchStorygraphLinkedBooks,
linkStorygraphBook,
rematchStorygraphBook,
setStorygraphEdition,
} from '../api/storygraph.api'
import { useStorygraphSync } from '../composables/useStorygraphSync'
function editionUrl(edition: StorygraphEdition): string {
return `https://app.thestorygraph.com/books/${edition.id}`
}
const books = ref<StorygraphLinkedBook[]>([])
const loading = ref(true)
const loadError = ref(false)
const expandedBookId = ref<number | null>(null)
const linkInputs = reactive<Record<number, string>>({})
const linking = reactive<Record<number, boolean>>({})
const rematching = reactive<Record<number, boolean>>({})
const editionsByBookId = reactive<Record<number, StorygraphEdition[]>>({})
const loadingEditions = reactive<Record<number, boolean>>({})
const settingEdition = reactive<Record<number, boolean>>({})
onMounted(async () => {
await loadBooks()
})
// A bulk sync can link books after this list was fetched, leaving rows stuck on
// "Not linked yet" until a manual refresh, so reload whenever a run finishes. The refresh is
// silent: toggling the loading state would swap the list for the spinner and collapse
// whatever row the user is mid-interaction with.
const { lastRunSummary } = useStorygraphSync()
watch(lastRunSummary, async (summary) => {
if (summary) await loadBooks({ silent: true })
})
// Swallows its own failures (loadError drives the UI) so post-action refreshes can't
// bubble into callers' try/catch and misattribute a reload failure to the action itself.
// The request id guards against concurrent reloads (mount, post-action, post-run) resolving
// out of order and clobbering newer results with stale ones.
let loadRequestId = 0
async function loadBooks(options?: { silent?: boolean }): Promise<void> {
const requestId = ++loadRequestId
if (!options?.silent) loading.value = true
loadError.value = false
try {
const rows = await fetchStorygraphLinkedBooks()
if (requestId === loadRequestId) books.value = rows
} catch {
if (requestId === loadRequestId) loadError.value = true
} finally {
if (requestId === loadRequestId && !options?.silent) loading.value = false
}
}
async function handleRetryLoadBooks(): Promise<void> {
await loadBooks()
}
function toggleExpanded(bookId: number) {
expandedBookId.value = expandedBookId.value === bookId ? null : bookId
}
// StoryGraph reports match methods as lowercase slugs; present them with real casing.
const MATCH_METHOD_LABELS: Record<string, string> = {
isbn: 'ISBN',
title: 'Title',
cached: 'Cached',
manual: 'Manual',
}
function matchMethodLabel(method: string): string {
return MATCH_METHOD_LABELS[method] ?? method
}
function statusLabel(book: StorygraphLinkedBook): string {
if (book.matchError) return `Error: ${book.matchError}`
if (book.storygraphBookId) return `Linked${book.matchMethod ? ` (${matchMethodLabel(book.matchMethod)})` : ''}`
return 'Not linked yet'
}
async function handleLink(book: StorygraphLinkedBook) {
const input = (linkInputs[book.bookId] ?? '').trim()
if (!input) {
toast.error('Paste a StoryGraph URL or book id first')
return
}
linking[book.bookId] = true
try {
const result = await linkStorygraphBook(book.bookId, input)
if (result.success) {
toast.success(`Linked: ${result.title || result.storygraphBookId}`)
linkInputs[book.bookId] = ''
delete editionsByBookId[book.bookId]
await loadBooks()
} else {
toast.error('Could not find that StoryGraph book')
}
} catch {
toast.error('Failed to link StoryGraph book')
} finally {
linking[book.bookId] = false
}
}
async function handleRematch(book: StorygraphLinkedBook) {
rematching[book.bookId] = true
try {
const { result } = await rematchStorygraphBook(book.bookId)
if (result === 'synced') toast.success('Re-matched and synced with StoryGraph')
else if (result === 'failed') toast.error('StoryGraph re-match failed, check the server logs')
else toast.info('StoryGraph sync is not connected for your account')
delete editionsByBookId[book.bookId]
await loadBooks()
} catch {
toast.error('Failed to re-match with StoryGraph')
} finally {
rematching[book.bookId] = false
}
}
async function loadEditions(book: StorygraphLinkedBook) {
if (editionsByBookId[book.bookId]) return
loadingEditions[book.bookId] = true
try {
editionsByBookId[book.bookId] = await fetchStorygraphEditions(book.bookId)
} finally {
loadingEditions[book.bookId] = false
}
}
async function handleSetEdition(book: StorygraphLinkedBook, edition: StorygraphEdition) {
settingEdition[book.bookId] = true
try {
const { success } = await setStorygraphEdition(book.bookId, edition.id)
if (success) {
toast.success(`Switched to ${edition.format}`)
delete editionsByBookId[book.bookId]
await loadBooks()
} else {
toast.error('Failed to switch edition')
}
} catch {
toast.error('Failed to switch edition')
} finally {
settingEdition[book.bookId] = false
}
}
</script>
<template>
<div class="border border-border rounded-lg bg-card px-4 py-4 md:px-5 md:py-5 shadow-xs space-y-4">
<div>
<p class="font-medium text-sm">Linked books</p>
<p class="text-xs text-muted-foreground mt-0.5">
Shows books you're currently reading. StoryGraph has no public API, so matching is sometimes wrong. Fix a book here by pasting its correct
StoryGraph URL, or pick a different edition (paperback, ebook, audiobook) once it's linked.
</p>
</div>
<div v-if="loading" class="flex items-center gap-2 text-xs text-muted-foreground py-4">
<Loader2 class="size-3.5 animate-spin" />
Loading books...
</div>
<div v-else-if="loadError" class="flex items-center gap-2 text-xs text-destructive py-2">
<AlertCircle class="size-3.5 shrink-0" />
Failed to load books.
<button type="button" class="underline underline-offset-2" @click="handleRetryLoadBooks">Retry</button>
</div>
<div v-else-if="books.length === 0" class="text-xs text-muted-foreground py-2">No books currently being read.</div>
<div v-else class="divide-y divide-border/60">
<div v-for="book in books" :key="book.bookId" class="py-2.5">
<button type="button" class="flex w-full items-center justify-between gap-2 text-left" @click="toggleExpanded(book.bookId)">
<div class="min-w-0">
<p class="text-sm truncate">{{ book.title ?? 'Untitled' }}</p>
<p class="text-xs text-muted-foreground truncate">{{ book.authorName ?? 'Unknown author' }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="flex items-center gap-1 text-xs"
:class="book.matchError ? 'text-destructive' : book.storygraphBookId ? 'text-green-600' : 'text-muted-foreground'"
>
<AlertCircle v-if="book.matchError" class="size-3.5" />
<CheckCircle2 v-else-if="book.storygraphBookId" class="size-3.5" />
<HelpCircle v-else class="size-3.5" />
{{ statusLabel(book) }}
</span>
<ChevronUp v-if="expandedBookId === book.bookId" class="size-3.5 text-muted-foreground" />
<ChevronDown v-else class="size-3.5 text-muted-foreground" />
</div>
</button>
<div v-if="expandedBookId === book.bookId" class="mt-3 space-y-3 pl-1">
<p v-if="!book.storygraphBookId" class="flex items-start gap-1.5 text-[11px] text-muted-foreground leading-relaxed">
<HelpCircle class="size-3.5 shrink-0 mt-0.5" />
This book couldn't be matched to StoryGraph automatically. Use "Try auto-match" to search again, or paste the book's StoryGraph URL, or
its StoryGraph book ID (the code after <code class="px-1 rounded bg-muted">/books/</code> in the URL, not an ISBN), below for an exact
link.
</p>
<div class="flex gap-2">
<input
v-model="linkInputs[book.bookId]"
type="text"
placeholder="Paste a StoryGraph URL or book ID"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<button
type="button"
:disabled="linking[book.bookId]"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="handleLink(book)"
>
<Loader2 v-if="linking[book.bookId]" class="size-3 animate-spin" />
<Link2 v-else class="size-3" />
Link
</button>
</div>
<button
type="button"
:disabled="rematching[book.bookId]"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="handleRematch(book)"
>
<Loader2 v-if="rematching[book.bookId]" class="size-3 animate-spin" />
<RefreshCw v-else class="size-3" />
Try auto-match
</button>
<div v-if="book.storygraphBookId">
<button
v-if="!editionsByBookId[book.bookId]"
type="button"
:disabled="loadingEditions[book.bookId]"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="loadEditions(book)"
>
<Loader2 v-if="loadingEditions[book.bookId]" class="size-3 animate-spin" />
View editions
</button>
<div v-else class="space-y-1.5">
<p v-if="editionsByBookId[book.bookId]!.length === 0" class="text-xs text-muted-foreground">No editions found.</p>
<div
v-for="edition in editionsByBookId[book.bookId]"
:key="edition.id"
class="flex items-center justify-between gap-2 rounded-md border border-border/70 bg-muted/40 px-2.5 py-1.5"
>
<div class="flex min-w-0 items-center gap-2.5">
<img
v-if="edition.coverUrl"
:src="edition.coverUrl"
alt=""
class="h-12 w-8 shrink-0 rounded-sm object-cover border border-border/60"
loading="lazy"
/>
<div class="min-w-0 space-y-0.5">
<p class="text-xs truncate">
<span v-if="edition.title" class="font-medium">{{ edition.title }} · </span>
{{ edition.format }}
<span v-if="edition.pages" class="text-muted-foreground">· {{ edition.pages }} pages</span>
<span v-if="edition.language" class="text-muted-foreground">· {{ edition.language }}</span>
</p>
<p class="text-[11px] text-muted-foreground break-words">
<span v-if="edition.publisher">{{ edition.publisher }}</span>
<span v-if="edition.publisher && edition.publicationDate"> · </span>
<span v-if="edition.publicationDate">{{ edition.publicationDate }}</span>
<span v-if="(edition.publisher || edition.publicationDate) && edition.isbn"> · </span>
<span v-if="edition.isbn">ISBN {{ edition.isbn }}</span>
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<a
:href="editionUrl(edition)"
target="_blank"
rel="noopener"
title="Open on StoryGraph"
aria-label="Open on StoryGraph"
class="flex items-center justify-center p-1.5 rounded-md border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<ExternalLink class="size-3.5" />
</a>
<button
type="button"
:disabled="settingEdition[book.bookId] || edition.id === book.storygraphBookId"
class="px-2 py-1 text-xs rounded-md border border-border bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="handleSetEdition(book, edition)"
>
{{ edition.id === book.storygraphBookId ? 'Current' : 'Use this' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import StorygraphConnectionCard from '../components/StorygraphConnectionCard.vue'
import StorygraphLinkedBooks from '../components/StorygraphLinkedBooks.vue'
import StorygraphSyncProgress from '../components/StorygraphSyncProgress.vue'
import { useStorygraphSettings } from '../composables/useStorygraphSettings'
import { useStorygraphSync } from '../composables/useStorygraphSync'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const { settings } = useStorygraphSettings()
const { fetchStatus, stopSyncTracking } = useStorygraphSync()
onMounted(async () => {
await fetchStatus()
})
onUnmounted(() => {
stopSyncTracking()
})
</script>
<template>
<div class="space-y-6">
<SettingsPageHeader v-if="!props.embedded" title="StoryGraph" subtitle="Sync your reading progress and status to The StoryGraph." />
<StorygraphConnectionCard />
<StorygraphSyncProgress v-if="settings?.cookiesConfigured" />
<StorygraphLinkedBooks v-if="settings?.cookiesConfigured" />
</div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AlertCircle, RefreshCw, XCircle, Loader2 } from '@lucide/vue'
import { useStorygraphSync } from '../composables/useStorygraphSync'
import { useStorygraphSettings } from '../composables/useStorygraphSettings'
const {
activeSyncStatus,
lastRunSummary,
syncFailures,
syncing,
isSyncing,
syncProgress,
pendingSummary,
loadingPending,
error,
startSync,
cancelSync,
} = useStorygraphSync()
const { settings } = useStorygraphSettings()
const progressLabel = computed(() => {
const s = activeSyncStatus.value
if (!s) return ''
return `${s.processedBooks} / ${s.totalBooks} books`
})
const liveCountsLabel = computed(() => {
const s = activeSyncStatus.value
if (!s) return ''
const parts = [`${s.syncedBooks} synced`]
if (s.skippedBooks > 0) parts.push(`${s.skippedBooks} skipped`)
return parts.join(' · ')
})
const lastRunLabel = computed(() => {
const s = lastRunSummary.value
if (!s) return null
const verb = s.status === 'cancelled' ? 'Last run cancelled' : 'Last run'
return `${verb}: ${s.syncedBooks} synced · ${s.skippedBooks} skipped · ${s.failedBooks} failed`
})
const failureLabel = (error: string): string => {
switch (error) {
case 'no_match':
return 'No StoryGraph match found'
case 'storygraph_session_expired':
return 'StoryGraph session expired, re-paste your cookies'
default:
return error.startsWith('status_update_failed') ? 'StoryGraph rejected the status update' : error
}
}
const hasPending = computed(() => pendingSummary.value.pendingBooks > 0)
const syncScopeLabel = computed(() => (settings.value?.bookSyncMode === 'selected_only' ? 'selected books' : 'eligible books'))
const syncUnavailableReason = computed(() => {
switch (settings.value?.disabledReason) {
case 'permission_denied':
return 'You do not have permission to use StoryGraph sync.'
case 'user_disabled':
return 'Sync is paused in your StoryGraph settings.'
default:
return null
}
})
const syncButtonDisabled = computed(() => syncing.value || loadingPending.value || !hasPending.value || syncUnavailableReason.value !== null)
const syncButtonLabel = computed(() => {
if (syncUnavailableReason.value) return 'Sync unavailable'
return hasPending.value ? `Sync now (${pendingSummary.value.pendingBooks})` : 'Sync now'
})
const lastSyncedLabel = computed(() => {
const iso = settings.value?.lastSyncedAt
if (!iso) return null
const diff = Date.now() - new Date(iso).getTime()
const minutes = Math.floor(diff / 60_000)
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes} min ago`
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${days} day${days === 1 ? '' : 's'} ago`
})
</script>
<template>
<div class="border border-border rounded-lg bg-card px-4 py-4 md:px-5 md:py-5 shadow-xs space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-medium text-sm">Manual sync</p>
<p class="text-xs text-muted-foreground mt-0.5">Push your {{ syncScopeLabel }} to StoryGraph now.</p>
<p v-if="!isSyncing" class="text-xs text-muted-foreground mt-1">
<template v-if="loadingPending">Checking pending items...</template>
<template v-else-if="hasPending">
{{ pendingSummary.pendingBooks }} pending of {{ pendingSummary.totalBooks }} books.
<span v-if="syncUnavailableReason">{{ syncUnavailableReason }}</span>
</template>
<template v-else-if="syncUnavailableReason">{{ syncUnavailableReason }}</template>
<template v-else-if="pendingSummary.totalBooks === 0">No books in sync scope.</template>
<template v-else>All books are already synced.</template>
</p>
<p v-if="lastSyncedLabel && !isSyncing" class="text-xs text-muted-foreground mt-0.5">
{{ syncUnavailableReason ? 'Last successful sync' : 'Last synced' }}: {{ lastSyncedLabel }}
</p>
<p v-if="error && !isSyncing" class="flex items-center gap-1 text-xs text-destructive mt-1">
<AlertCircle class="size-3.5" />
{{ error }}
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="!isSyncing"
type="button"
:disabled="syncButtonDisabled"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="startSync"
>
<Loader2 v-if="syncing" class="size-3.5 animate-spin" />
<RefreshCw v-else class="size-3.5" />
{{ syncButtonLabel }}
</button>
<button
v-else
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
@click="cancelSync"
>
<XCircle class="size-3.5" />
Cancel
</button>
</div>
</div>
<div v-if="isSyncing" class="space-y-1.5">
<div class="flex justify-between text-xs text-muted-foreground">
<span>
Syncing... {{ liveCountsLabel }}
<span v-if="activeSyncStatus && activeSyncStatus.failedBooks > 0" class="text-destructive"
>· {{ activeSyncStatus.failedBooks }} failed</span
>
</span>
<span>{{ progressLabel }}</span>
</div>
<div class="h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" :style="{ width: `${syncProgress}%` }" />
</div>
</div>
<p v-if="lastRunLabel && !isSyncing" class="text-xs text-muted-foreground">
{{ lastRunLabel }}
</p>
<div v-if="syncFailures.length > 0 && !isSyncing" class="space-y-1.5 pt-2 border-t border-border">
<p class="flex items-center gap-1.5 text-xs font-medium text-destructive">
<AlertCircle class="size-3.5 shrink-0" />
{{ syncFailures.length }} book{{ syncFailures.length === 1 ? '' : 's' }} failed to sync
</p>
<ul class="space-y-1">
<li v-for="failure in syncFailures" :key="failure.bookId" class="text-xs text-muted-foreground">
<span class="text-foreground">{{ failure.title }}</span>
<span v-if="failure.authorName"> - {{ failure.authorName }}</span>
<span class="text-destructive"> · {{ failureLabel(failure.syncError) }}</span>
</li>
</ul>
</div>
</div>
</template>
@@ -0,0 +1,482 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import type { StorygraphBookSyncState, StorygraphSettings } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { api } from '@/lib/api'
import { useStorygraphSettings } from '../../composables/useStorygraphSettings'
import StorygraphBookSyncGridItem from '../StorygraphBookSyncGridItem.vue'
type ApiFn = (input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }) => Promise<Response>
const mocks = vi.hoisted(() => ({
hasPermission: vi.fn<(...args: unknown[]) => boolean>(),
settings: {
value: null as StorygraphSettings | null,
},
bookStates: new Map<number, StorygraphBookSyncState>(),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: mocks.hasPermission }),
}))
vi.mock('@/lib/api', () => ({
api: vi.fn<ApiFn>(),
}))
const mockApi = vi.mocked(api)
function jsonResponse(body: unknown, ok = true): Response {
return {
ok,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
} as unknown as Response
}
function makeSettings(overrides: Partial<StorygraphSettings> = {}): StorygraphSettings {
return {
cookiesConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
bookSyncMode: 'all_eligible',
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
lastSyncedAt: null,
...overrides,
}
}
function makeBookState(overrides: Partial<StorygraphBookSyncState> = {}): StorygraphBookSyncState {
return {
bookId: 12,
syncOverride: null,
syncEnabled: true,
canSyncNow: true,
effectiveReason: null,
lastSyncedAt: null,
syncError: null,
...overrides,
}
}
function setBookState(bookId: number, overrides: Partial<StorygraphBookSyncState> = {}): StorygraphBookSyncState {
const state = { ...makeBookState({ bookId }), ...overrides }
mocks.bookStates.set(bookId, state)
return state
}
function getBookState(bookId: number): StorygraphBookSyncState {
return mocks.bookStates.get(bookId) ?? setBookState(bookId)
}
function mountItem(bookId = 12) {
return mount(StorygraphBookSyncGridItem, { props: { bookId } })
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
describe('StorygraphBookSyncGridItem', () => {
const { settings } = useStorygraphSettings()
let wrappers: Array<{ unmount: () => void }> = []
beforeEach(() => {
vi.clearAllMocks()
settings.value = null
mocks.hasPermission.mockReturnValue(true)
mocks.settings.value = makeSettings()
mocks.bookStates.clear()
setBookState(12)
mockApi.mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/storygraph/settings')) {
return jsonResponse(mocks.settings.value)
}
const syncStateMatch = url.match(/\/storygraph\/books\/(\d+)\/sync-state$/)
if (syncStateMatch) {
const bookId = Number(syncStateMatch[1])
if (init?.method === 'PATCH') {
const body = typeof init.body === 'string' ? (JSON.parse(init.body) as { syncEnabled?: boolean }) : {}
const current = getBookState(bookId)
const nextState: StorygraphBookSyncState =
mocks.settings.value?.bookSyncMode === 'selected_only'
? body.syncEnabled
? {
...current,
syncOverride: 'included',
syncEnabled: true,
canSyncNow: true,
effectiveReason: null,
syncError: null,
}
: {
...current,
syncOverride: null,
syncEnabled: false,
canSyncNow: false,
effectiveReason: 'not_selected',
syncError: null,
}
: body.syncEnabled
? {
...current,
syncOverride: null,
syncEnabled: true,
canSyncNow: true,
effectiveReason: null,
syncError: null,
}
: {
...current,
syncOverride: 'excluded',
syncEnabled: false,
canSyncNow: false,
effectiveReason: 'excluded',
syncError: null,
}
mocks.bookStates.set(bookId, nextState)
return jsonResponse(nextState)
}
return jsonResponse(getBookState(bookId))
}
const syncMatch = url.match(/\/storygraph\/books\/(\d+)\/sync$/)
if (syncMatch) {
const bookId = Number(syncMatch[1])
const current = getBookState(bookId)
const nextState: StorygraphBookSyncState = {
...current,
syncEnabled: true,
canSyncNow: false,
effectiveReason: null,
lastSyncedAt: '2024-02-01T15:30:00.000Z',
syncError: null,
}
mocks.bookStates.set(bookId, nextState)
return jsonResponse({ result: 'synced', state: nextState })
}
return jsonResponse({}, false)
})
})
afterEach(() => {
for (const wrapper of wrappers) wrapper.unmount()
wrappers = []
})
it('shows the per-book sync state when Storygraph sync is enabled', async () => {
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(mockApi).toHaveBeenCalledWith('/api/v1/storygraph/settings')
expect(mockApi).toHaveBeenCalledWith('/api/v1/storygraph/books/12/sync-state')
expect(wrapper.text()).toContain('StoryGraph Sync')
expect(wrapper.text()).toContain('Pending sync')
expect(wrapper.text()).toContain('Sync now')
expect(wrapper.findComponent(ToggleSwitch).props('modelValue')).toBe(true)
})
it('does not fetch per-book state when the user lacks Storygraph sync permission', async () => {
mocks.hasPermission.mockReturnValue(false)
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
expect(wrapper.text()).toBe('')
expect(mockApi).not.toHaveBeenCalledWith('/api/v1/storygraph/settings')
expect(mockApi).not.toHaveBeenCalledWith('/api/v1/storygraph/books/12/sync-state')
})
it('does not fetch per-book state when global Storygraph sync is disabled', async () => {
mocks.settings.value = makeSettings({ enabled: false, effectiveEnabled: false })
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.text()).toBe('')
expect(mockApi).toHaveBeenCalledWith('/api/v1/storygraph/settings')
expect(mockApi).not.toHaveBeenCalledWith('/api/v1/storygraph/books/12/sync-state')
})
it('shows the last synced timestamp when the book has already synced', async () => {
setBookState(12, {
syncEnabled: true,
canSyncNow: false,
lastSyncedAt: '2024-02-01T15:30:00.000Z',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.text()).toContain('Synced')
expect(wrapper.text()).toContain('2024')
expect(wrapper.findAll('button').some((button) => button.text().includes('Sync now'))).toBe(false)
})
it('shows the stored sync error for a book', async () => {
setBookState(12, {
syncEnabled: false,
syncOverride: 'excluded',
canSyncNow: false,
effectiveReason: 'excluded',
syncError: 'timeout',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.text()).toContain('Error: timeout')
})
it('shows a load error when the per-book state cannot be fetched', async () => {
mockApi.mockImplementation(async (input) => {
const url = String(input)
if (url.endsWith('/storygraph/settings')) return jsonResponse(mocks.settings.value)
if (url.endsWith('/storygraph/books/12/sync-state')) return jsonResponse({}, false)
return jsonResponse({}, false)
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.text()).toContain('Unable to load StoryGraph sync state.')
})
it('waits for fresh settings before using shared Storygraph settings state', async () => {
settings.value = makeSettings()
mocks.settings.value = makeSettings({ enabled: false, effectiveEnabled: false })
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.text()).toBe('')
expect(mockApi).not.toHaveBeenCalledWith('/api/v1/storygraph/books/12/sync-state')
})
it('saves the per-book exclusion setting', async () => {
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
wrapper.findComponent(ToggleSwitch).vm.$emit('update:modelValue', false)
await flushPromises()
expect(mockApi).toHaveBeenCalledWith(
'/api/v1/storygraph/books/12/sync-state',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ syncEnabled: false }),
}),
)
expect(wrapper.text()).toContain('Excluded')
expect(wrapper.findAll('button').some((button) => button.text().includes('Sync now'))).toBe(false)
})
it('rolls back the per-book exclusion setting when saving fails', async () => {
mockApi.mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/storygraph/settings')) return jsonResponse(mocks.settings.value)
if (url.endsWith('/storygraph/books/12/sync-state') && init?.method === 'PATCH') return jsonResponse({ message: 'Forbidden' }, false)
if (url.endsWith('/storygraph/books/12/sync-state')) return jsonResponse(getBookState(12))
return jsonResponse({}, false)
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
wrapper.findComponent(ToggleSwitch).vm.$emit('update:modelValue', false)
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('modelValue')).toBe(true)
expect(wrapper.text()).toContain('Failed to save StoryGraph sync setting.')
expect(wrapper.findAll('button').some((button) => button.text().includes('Sync now'))).toBe(true)
})
it('selects a book in selected-only mode and syncs it manually', async () => {
mocks.settings.value = makeSettings({ bookSyncMode: 'selected_only' })
setBookState(12, {
syncEnabled: false,
syncOverride: null,
canSyncNow: false,
effectiveReason: 'not_selected',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.text()).toContain('Not selected')
expect(wrapper.findAll('button').some((button) => button.text().includes('Sync now'))).toBe(false)
wrapper.findComponent(ToggleSwitch).vm.$emit('update:modelValue', true)
await flushPromises()
expect(mockApi).toHaveBeenCalledWith(
'/api/v1/storygraph/books/12/sync-state',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ syncEnabled: true }),
}),
)
expect(wrapper.text()).toContain('Pending sync')
expect(wrapper.text()).toContain('Sync now')
const syncNowButton = wrapper.findAll('button').find((button) => button.text().includes('Sync now'))
expect(syncNowButton).toBeDefined()
await syncNowButton!.trigger('click')
await flushPromises()
await flushPromises()
expect(mockApi).toHaveBeenCalledWith('/api/v1/storygraph/books/12/sync', { method: 'POST' })
expect(wrapper.text()).toContain('Synced')
})
it('shows toggle ON with "Will sync when reading starts" for an unread book in all_eligible mode', async () => {
setBookState(12, {
syncEnabled: false,
syncOverride: null,
canSyncNow: false,
effectiveReason: 'unread',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('modelValue')).toBe(true)
expect(wrapper.text()).toContain('Will sync when reading starts')
expect(wrapper.findAll('button').some((button) => button.text().includes('Sync now'))).toBe(false)
})
it('shows toggle OFF with "Excluded" for an unread book explicitly excluded in all_eligible mode', async () => {
setBookState(12, {
syncEnabled: false,
syncOverride: 'excluded',
canSyncNow: false,
effectiveReason: 'unread',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('modelValue')).toBe(false)
expect(wrapper.text()).toContain('Excluded')
})
it('shows toggle OFF with "Excluded" for an unread book not yet included in selected_only mode', async () => {
mocks.settings.value = makeSettings({ bookSyncMode: 'selected_only' })
setBookState(12, {
syncEnabled: false,
syncOverride: null,
canSyncNow: false,
effectiveReason: 'unread',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('modelValue')).toBe(false)
expect(wrapper.text()).toContain('Excluded')
})
it('shows toggle ON with "Will sync when reading starts" for an unread book included in selected_only mode', async () => {
mocks.settings.value = makeSettings({ bookSyncMode: 'selected_only' })
setBookState(12, {
syncEnabled: false,
syncOverride: 'included',
canSyncNow: false,
effectiveReason: 'unread',
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('modelValue')).toBe(true)
expect(wrapper.text()).toContain('Will sync when reading starts')
expect(wrapper.findAll('button').some((button) => button.text().includes('Sync now'))).toBe(false)
})
it('clears the saving state when a save finishes after the book changes', async () => {
const patchResponse = deferred<Response>()
mockApi.mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/storygraph/settings')) return jsonResponse(mocks.settings.value)
if (url.endsWith('/storygraph/books/12/sync-state') && init?.method === 'PATCH') return patchResponse.promise
if (url.endsWith('/storygraph/books/12/sync-state')) return jsonResponse(getBookState(12))
if (url.endsWith('/storygraph/books/13/sync-state')) {
return jsonResponse(
makeBookState({
bookId: 13,
syncEnabled: true,
canSyncNow: false,
lastSyncedAt: null,
syncError: null,
}),
)
}
return jsonResponse({}, false)
})
const wrapper = mountItem()
wrappers.push(wrapper)
await flushPromises()
await flushPromises()
wrapper.findComponent(ToggleSwitch).vm.$emit('update:modelValue', false)
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(true)
await wrapper.setProps({ bookId: 13 })
await flushPromises()
patchResponse.resolve(
jsonResponse({
...getBookState(12),
syncEnabled: false,
syncOverride: 'excluded',
canSyncNow: false,
effectiveReason: 'excluded',
}),
)
await flushPromises()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(false)
expect(wrapper.text()).toContain('Included')
})
})
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { ref } from 'vue'
import type { StorygraphSettings, UpsertStorygraphSettingsPayload } from '@bookorbit/types'
import StorygraphConnectionCard from '../StorygraphConnectionCard.vue'
const settings = ref<StorygraphSettings | null>(null)
const loading = ref(false)
const saving = ref(false)
const validating = ref(false)
const error = ref<string | null>(null)
const mocks = vi.hoisted(() => ({
fetchSettings: vi.fn<() => Promise<void>>(),
saveSettings: vi.fn<(payload: UpsertStorygraphSettingsPayload) => Promise<boolean>>(),
disconnect: vi.fn<() => Promise<void>>(),
validateCookies: vi.fn<(sessionCookie?: string, rememberToken?: string) => Promise<{ valid: boolean }>>(),
}))
const toastSuccess = vi.hoisted(() => vi.fn<(message: string) => void>())
const toastError = vi.hoisted(() => vi.fn<(message: string) => void>())
vi.mock('../../composables/useStorygraphSettings', () => ({
useStorygraphSettings: () => ({
settings,
loading,
saving,
validating,
error,
fetchSettings: mocks.fetchSettings,
saveSettings: mocks.saveSettings,
disconnect: mocks.disconnect,
validateCookies: mocks.validateCookies,
}),
}))
vi.mock('vue-sonner', () => ({
toast: {
success: toastSuccess,
error: toastError,
},
}))
function makeSettings(overrides: Partial<StorygraphSettings> = {}): StorygraphSettings {
return {
cookiesConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
bookSyncMode: 'all_eligible',
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
lastSyncedAt: null,
...overrides,
}
}
function findButton(wrapper: ReturnType<typeof mount>, label: string) {
return wrapper.findAll('button').find((b) => b.text().includes(label))
}
function hasValidationBadge(wrapper: ReturnType<typeof mount>): boolean {
return wrapper.findAll('span').some((s) => s.text().trim() === 'Valid' || s.text().includes('Invalid or expired'))
}
function cookieInputs(wrapper: ReturnType<typeof mount>) {
const session = wrapper.find('input[placeholder="Paste the _storygraph_session cookie value"]')
const remember = wrapper.find('input[placeholder="Paste the remember_user_token cookie value"]')
return { session, remember }
}
describe('StorygraphConnectionCard', () => {
beforeEach(() => {
vi.clearAllMocks()
settings.value = makeSettings()
loading.value = false
saving.value = false
validating.value = false
error.value = null
mocks.saveSettings.mockResolvedValue(true)
mocks.disconnect.mockResolvedValue()
mocks.validateCookies.mockResolvedValue({ valid: true })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the connected state with sync option toggles', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('Enable sync')
expect(wrapper.text()).toContain('Sync on status change')
expect(wrapper.text()).toContain('Sync on progress update')
expect(mocks.fetchSettings).toHaveBeenCalled()
})
it('validates cookies and shows the result badge', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
const { session, remember } = cookieInputs(wrapper)
await session.setValue('sess-value')
await remember.setValue('remember-value')
await findButton(wrapper, 'Validate cookies')!.trigger('click')
await flushPromises()
expect(mocks.validateCookies).toHaveBeenCalledWith('sess-value', 'remember-value')
expect(hasValidationBadge(wrapper)).toBe(true)
})
it('clears a stale validation badge when a cookie input changes', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
const { session, remember } = cookieInputs(wrapper)
await session.setValue('sess-value')
await remember.setValue('remember-value')
await findButton(wrapper, 'Validate cookies')!.trigger('click')
await flushPromises()
expect(hasValidationBadge(wrapper)).toBe(true)
await session.setValue('sess-value-edited')
await flushPromises()
expect(hasValidationBadge(wrapper)).toBe(false)
})
it('saves new cookies together with the sync options', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
const { session, remember } = cookieInputs(wrapper)
await session.setValue('new-sess')
await remember.setValue('new-remember')
await findButton(wrapper, 'Save')!.trigger('click')
await flushPromises()
expect(mocks.saveSettings).toHaveBeenCalledWith(expect.objectContaining({ sessionCookie: 'new-sess', rememberToken: 'new-remember' }))
expect(toastSuccess).toHaveBeenCalled()
})
it('rejects a save when only one cookie value is entered instead of silently dropping it', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
const { session } = cookieInputs(wrapper)
await session.setValue('only-one-cookie')
await findButton(wrapper, 'Save')!.trigger('click')
await flushPromises()
expect(mocks.saveSettings).not.toHaveBeenCalled()
expect(toastError).toHaveBeenCalledWith('Enter both cookie values to update the connection')
expect(toastSuccess).not.toHaveBeenCalled()
})
it('saves toggles without cookie fields when the inputs are empty', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
await findButton(wrapper, 'Save')!.trigger('click')
await flushPromises()
expect(mocks.saveSettings).toHaveBeenCalledTimes(1)
const payload = mocks.saveSettings.mock.calls[0]![0]
expect(payload).not.toHaveProperty('sessionCookie')
expect(payload).not.toHaveProperty('rememberToken')
})
it('disconnects and clears the inputs', async () => {
const wrapper = mount(StorygraphConnectionCard)
await flushPromises()
await findButton(wrapper, 'Disconnect')!.trigger('click')
await flushPromises()
expect(mocks.disconnect).toHaveBeenCalled()
expect(toastSuccess).toHaveBeenCalledWith('StoryGraph disconnected')
})
})
@@ -0,0 +1,252 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { ref } from 'vue'
import type { StorygraphActiveSyncStatus, StorygraphLinkedBook } from '@bookorbit/types'
import StorygraphLinkedBooks from '../StorygraphLinkedBooks.vue'
const lastRunSummary = ref<StorygraphActiveSyncStatus | null>(null)
const mocks = vi.hoisted(() => ({
fetchStorygraphLinkedBooks: vi.fn<() => Promise<StorygraphLinkedBook[]>>(),
fetchStorygraphEditions: vi.fn<(bookId: number) => Promise<unknown[]>>(),
linkStorygraphBook: vi.fn<(bookId: number, input: string) => Promise<{ success: boolean; storygraphBookId?: string; title?: string }>>(),
rematchStorygraphBook: vi.fn<(bookId: number) => Promise<{ result: string }>>(),
setStorygraphEdition: vi.fn<(bookId: number, editionId: string) => Promise<{ success: boolean }>>(),
}))
const toastSuccess = vi.hoisted(() => vi.fn<(message: string) => void>())
const toastError = vi.hoisted(() => vi.fn<(message: string) => void>())
vi.mock('../../api/storygraph.api', () => mocks)
vi.mock('../../composables/useStorygraphSync', () => ({
useStorygraphSync: () => ({ lastRunSummary }),
}))
vi.mock('vue-sonner', () => ({
toast: {
success: toastSuccess,
error: toastError,
info: vi.fn<(message: string) => void>(),
},
}))
function makeBook(overrides: Partial<StorygraphLinkedBook> = {}): StorygraphLinkedBook {
return {
bookId: 12,
title: 'A Parade of Horribles',
author: 'Matt Dinniman',
storygraphBookId: 'sg-1',
matchMethod: 'cached',
matchError: null,
...overrides,
} as StorygraphLinkedBook
}
describe('StorygraphLinkedBooks', () => {
let wrappers: Array<{ unmount: () => void }> = []
function mountList() {
const wrapper = mount(StorygraphLinkedBooks)
wrappers.push(wrapper)
return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
lastRunSummary.value = null
mocks.fetchStorygraphLinkedBooks.mockResolvedValue([makeBook()])
})
afterEach(() => {
for (const wrapper of wrappers) wrapper.unmount()
wrappers = []
vi.restoreAllMocks()
})
it('renders the linked books returned by the API', async () => {
const wrapper = mountList()
await flushPromises()
expect(wrapper.text()).toContain('A Parade of Horribles')
expect(wrapper.text()).toContain('Linked (Cached)')
})
it.each([
['isbn', 'Linked (ISBN)'],
['title', 'Linked (Title)'],
['manual', 'Linked (Manual)'],
])('presents the %s match method with proper casing', async (matchMethod, expected) => {
mocks.fetchStorygraphLinkedBooks.mockResolvedValue([makeBook({ matchMethod: matchMethod as StorygraphLinkedBook['matchMethod'] })])
const wrapper = mountList()
await flushPromises()
expect(wrapper.text()).toContain(expected)
})
it('labels the manual-link field for a StoryGraph URL or book ID', async () => {
mocks.fetchStorygraphLinkedBooks.mockResolvedValue([makeBook({ storygraphBookId: null, matchMethod: null })])
const wrapper = mountList()
await flushPromises()
await wrapper.find('button').trigger('click')
expect(wrapper.find('input[type="text"]').attributes('placeholder')).toBe('Paste a StoryGraph URL or book ID')
})
it('shows an empty state when no books are being read', async () => {
mocks.fetchStorygraphLinkedBooks.mockResolvedValue([])
const wrapper = mountList()
await flushPromises()
expect(wrapper.text()).toContain('No books currently being read.')
})
it('shows a retryable error state instead of an unhandled rejection when loading fails', async () => {
mocks.fetchStorygraphLinkedBooks.mockRejectedValueOnce(new Error('network down'))
const wrapper = mountList()
await flushPromises()
expect(wrapper.text()).toContain('Failed to load books.')
mocks.fetchStorygraphLinkedBooks.mockResolvedValueOnce([makeBook()])
const retry = wrapper.findAll('button').find((b) => b.text() === 'Retry')
await retry!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('A Parade of Horribles')
})
it('shows distinguishing edition identifiers in the editions list', async () => {
const wrapper = mountList()
await flushPromises()
mocks.fetchStorygraphEditions.mockResolvedValue([
{
id: 'sg-1',
title: 'First Printing',
format: 'Hardcover',
pages: 478,
isAudio: false,
language: 'English',
isbn: '9781234567890',
publisher: 'Ace Books',
publicationDate: '12 March 2024',
coverUrl: 'https://cdn.thestorygraph.com/covers/sg-1.jpg',
},
{
id: 'sg-9',
title: 'Special Edition',
format: 'Hardcover',
pages: 478,
isAudio: false,
language: 'English',
isbn: '9780987654321',
publisher: null,
publicationDate: null,
coverUrl: null,
},
])
await wrapper.find('button').trigger('click')
const viewEditions = wrapper.findAll('button').find((b) => b.text().includes('View editions'))
await viewEditions!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('ISBN 9781234567890')
expect(wrapper.text()).toContain('ISBN 9780987654321')
expect(wrapper.text()).toContain('Ace Books')
expect(wrapper.text()).toContain('12 March 2024')
expect(wrapper.text()).toContain('First Printing')
const editionLinks = wrapper.findAll('a[target="_blank"]').map((a) => a.attributes('href'))
expect(editionLinks).toContain('https://app.thestorygraph.com/books/sg-9')
const cover = wrapper.find('img[src="https://cdn.thestorygraph.com/covers/sg-1.jpg"]')
expect(cover.exists()).toBe(true)
// sg-1 is the currently linked edition (matches storygraphBookId in makeBook)
const currentButton = wrapper.findAll('button').find((b) => b.text().trim() === 'Current')
expect(currentButton).toBeDefined()
})
it('reloads the list when a sync run completes', async () => {
const wrapper = mountList()
await flushPromises()
mocks.fetchStorygraphLinkedBooks.mockResolvedValue([makeBook({ storygraphBookId: 'sg-new', matchMethod: 'isbn' })])
lastRunSummary.value = {
runId: 2,
status: 'completed',
syncedBooks: 2,
skippedBooks: 0,
failedBooks: 0,
processedBooks: 2,
totalBooks: 2,
}
await flushPromises()
expect(mocks.fetchStorygraphLinkedBooks).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Linked (ISBN)')
// Silent refresh: the list stays rendered instead of flashing the loading spinner.
expect(wrapper.text()).not.toContain('Loading books...')
})
it('shows a linking hint for books without a StoryGraph match', async () => {
mocks.fetchStorygraphLinkedBooks.mockResolvedValue([makeBook({ storygraphBookId: null, matchMethod: null })])
const wrapper = mountList()
await flushPromises()
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain("couldn't be matched to StoryGraph automatically")
expect(wrapper.text()).toContain('Try auto-match')
})
it('shows an icon-only edition link instead of the raw edition id', async () => {
const wrapper = mountList()
await flushPromises()
mocks.fetchStorygraphEditions.mockResolvedValue([
{
id: 'sg-1',
title: 'First Printing',
format: 'Hardcover',
pages: 478,
isAudio: false,
language: 'English',
isbn: '9781234567890',
publisher: null,
publicationDate: null,
coverUrl: null,
},
])
await wrapper.find('button').trigger('click')
const viewEditions = wrapper.findAll('button').find((b) => b.text().includes('View editions'))
await viewEditions!.trigger('click')
await flushPromises()
const link = wrapper.find('a[href="https://app.thestorygraph.com/books/sg-1"]')
expect(link.exists()).toBe(true)
expect(link.text().trim()).toBe('')
expect(link.attributes('aria-label')).toBe('Open on StoryGraph')
expect(link.attributes('target')).toBe('_blank')
})
it('does not misattribute a reload failure to a successful link action', async () => {
const wrapper = mountList()
await flushPromises()
await wrapper.find('button').trigger('click')
const input = wrapper.find('input')
await input.setValue('https://app.thestorygraph.com/books/sg-2')
mocks.linkStorygraphBook.mockResolvedValue({ success: true, storygraphBookId: 'sg-2', title: 'Linked Title' })
mocks.fetchStorygraphLinkedBooks.mockRejectedValueOnce(new Error('reload failed'))
const linkButton = wrapper.findAll('button').find((b) => b.text().trim() === 'Link')
await linkButton!.trigger('click')
await flushPromises()
expect(toastSuccess).toHaveBeenCalledWith('Linked: Linked Title')
expect(toastError).not.toHaveBeenCalledWith('Failed to link StoryGraph book')
expect(wrapper.text()).toContain('Failed to load books.')
})
})
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import type { StorygraphActiveSyncStatus, StorygraphSettings, StorygraphSyncFailure } from '@bookorbit/types'
import StorygraphSyncProgress from '../StorygraphSyncProgress.vue'
const activeSyncStatus = ref<StorygraphActiveSyncStatus | null>(null)
const lastRunSummary = ref<StorygraphActiveSyncStatus | null>(null)
const syncFailures = ref<StorygraphSyncFailure[]>([])
const syncing = ref(false)
const pendingSummary = ref({ totalBooks: 0, pendingBooks: 0 })
const loadingPending = ref(false)
const error = ref<string | null>(null)
const settings = ref<StorygraphSettings | null>(null)
const isSyncing = computed(() => activeSyncStatus.value?.status === 'running')
const syncProgress = computed(() => {
const status = activeSyncStatus.value
if (!status || status.totalBooks === 0) return 0
return Math.round((status.processedBooks / status.totalBooks) * 100)
})
const mocks = vi.hoisted(() => ({
startSync: vi.fn<() => Promise<void>>(),
cancelSync: vi.fn<() => Promise<void>>(),
fetchStatus: vi.fn<() => Promise<void>>(),
fetchPendingSummary: vi.fn<() => Promise<void>>(),
fetchSyncFailures: vi.fn<() => Promise<void>>(),
stopSyncTracking: vi.fn<() => void>(),
}))
vi.mock('../../composables/useStorygraphSettings', () => ({
useStorygraphSettings: () => ({ settings }),
}))
vi.mock('../../composables/useStorygraphSync', () => ({
useStorygraphSync: () => ({
activeSyncStatus,
lastRunSummary,
syncFailures,
syncing,
pendingSummary,
loadingPending,
error,
isSyncing,
syncProgress,
startSync: mocks.startSync,
cancelSync: mocks.cancelSync,
fetchStatus: mocks.fetchStatus,
fetchPendingSummary: mocks.fetchPendingSummary,
fetchSyncFailures: mocks.fetchSyncFailures,
stopSyncTracking: mocks.stopSyncTracking,
}),
}))
function makeSettings(overrides: Partial<StorygraphSettings> = {}): StorygraphSettings {
return {
cookiesConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
bookSyncMode: 'all_eligible',
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
lastSyncedAt: null,
...overrides,
}
}
function makeStatus(overrides: Partial<StorygraphActiveSyncStatus> = {}): StorygraphActiveSyncStatus {
return {
runId: 1,
status: 'running',
syncedBooks: 0,
skippedBooks: 0,
failedBooks: 0,
processedBooks: 0,
totalBooks: 19,
...overrides,
}
}
describe('StorygraphSyncProgress', () => {
beforeEach(() => {
vi.clearAllMocks()
activeSyncStatus.value = null
lastRunSummary.value = null
syncFailures.value = []
syncing.value = false
pendingSummary.value = { totalBooks: 0, pendingBooks: 0 }
loadingPending.value = false
error.value = null
settings.value = makeSettings()
})
it('shows processed progress that advances with skipped and failed books', async () => {
activeSyncStatus.value = makeStatus({ processedBooks: 7, syncedBooks: 4, skippedBooks: 2, failedBooks: 1 })
const wrapper = mount(StorygraphSyncProgress)
await flushPromises()
expect(wrapper.text()).toContain('7 / 19 books')
expect(wrapper.text()).toContain('4 synced')
expect(wrapper.text()).toContain('2 skipped')
expect(wrapper.text()).toContain('1 failed')
const bar = wrapper.find('.bg-primary.transition-all')
expect(bar.attributes('style')).toContain('37%')
})
it('hides the failed counter while no book has failed', async () => {
activeSyncStatus.value = makeStatus({ processedBooks: 3, syncedBooks: 3 })
const wrapper = mount(StorygraphSyncProgress)
await flushPromises()
expect(wrapper.text()).not.toContain('failed')
})
it('shows the last run summary after a completed sync', async () => {
lastRunSummary.value = makeStatus({ status: 'completed', processedBooks: 19, syncedBooks: 15, skippedBooks: 3, failedBooks: 1 })
const wrapper = mount(StorygraphSyncProgress)
await flushPromises()
expect(wrapper.text()).toContain('Last run: 15 synced · 3 skipped · 1 failed')
})
it('lists books that failed to sync with readable reasons', async () => {
syncFailures.value = [
{ bookId: 7, title: 'Broken Book', authorName: 'Some Author', syncError: 'no_match', lastAttemptAt: null },
{ bookId: 8, title: 'Expired Book', authorName: null, syncError: 'storygraph_session_expired', lastAttemptAt: null },
]
const wrapper = mount(StorygraphSyncProgress)
await flushPromises()
expect(wrapper.text()).toContain('2 books failed to sync')
expect(wrapper.text()).toContain('Broken Book')
expect(wrapper.text()).toContain('No StoryGraph match found')
expect(wrapper.text()).toContain('Expired Book')
expect(wrapper.text()).toContain('re-paste your cookies')
})
it('hides the failure list while a sync is running', async () => {
syncFailures.value = [{ bookId: 7, title: 'Broken Book', authorName: null, syncError: 'no_match', lastAttemptAt: null }]
activeSyncStatus.value = makeStatus({ processedBooks: 1 })
const wrapper = mount(StorygraphSyncProgress)
await flushPromises()
expect(wrapper.text()).not.toContain('failed to sync')
})
})
@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StorygraphCookieValidationResult, StorygraphSettings, UpsertStorygraphSettingsPayload } from '@bookorbit/types'
vi.mock('../../api/storygraph.api', () => ({
fetchStorygraphSettings: vi.fn<() => Promise<StorygraphSettings>>(),
upsertStorygraphSettings: vi.fn<(payload: UpsertStorygraphSettingsPayload) => Promise<StorygraphSettings>>(),
disconnectStorygraph: vi.fn<() => Promise<void>>(),
validateStorygraphCookies: vi.fn<(sessionCookie?: string, rememberToken?: string) => Promise<StorygraphCookieValidationResult>>(),
}))
import { disconnectStorygraph, fetchStorygraphSettings, upsertStorygraphSettings, validateStorygraphCookies } from '../../api/storygraph.api'
const mockFetchSettings = vi.mocked(fetchStorygraphSettings)
const mockUpsert = vi.mocked(upsertStorygraphSettings)
const mockDisconnect = vi.mocked(disconnectStorygraph)
const mockValidate = vi.mocked(validateStorygraphCookies)
const SETTINGS: StorygraphSettings = {
cookiesConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
bookSyncMode: 'all_eligible',
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
lastSyncedAt: null,
}
async function loadComposable() {
const { useStorygraphSettings } = await import('../useStorygraphSettings')
return useStorygraphSettings()
}
describe('useStorygraphSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('fetchSettings populates settings on success', async () => {
mockFetchSettings.mockResolvedValue(SETTINGS)
const c = await loadComposable()
await c.fetchSettings()
expect(c.settings.value).toEqual(SETTINGS)
expect(c.loading.value).toBe(false)
expect(c.error.value).toBeNull()
})
it('fetchSettings sets error on failure', async () => {
mockFetchSettings.mockRejectedValue(new Error('Network error'))
const c = await loadComposable()
await c.fetchSettings()
expect(c.settings.value).toBeNull()
expect(c.error.value).toBe('Network error')
expect(c.loading.value).toBe(false)
})
it('saveSettings updates settings on success', async () => {
const payload: UpsertStorygraphSettingsPayload = {
sessionCookie: 'sess',
rememberToken: 'remember',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
}
mockUpsert.mockResolvedValue(SETTINGS)
const c = await loadComposable()
const ok = await c.saveSettings(payload)
expect(ok).toBe(true)
expect(c.settings.value).toEqual(SETTINGS)
expect(c.saving.value).toBe(false)
})
it('saveSettings returns false on failure', async () => {
mockUpsert.mockRejectedValue(new Error('Bad cookies'))
const c = await loadComposable()
const ok = await c.saveSettings({ sessionCookie: 'bad', rememberToken: 'bad' })
expect(ok).toBe(false)
expect(c.error.value).toBe('Bad cookies')
})
it('disconnect clears settings', async () => {
mockDisconnect.mockResolvedValue(undefined)
mockFetchSettings.mockResolvedValue(SETTINGS)
const c = await loadComposable()
await c.fetchSettings()
await c.disconnect()
expect(c.settings.value).toBeNull()
})
it('validateCookies returns valid true on success', async () => {
const result: StorygraphCookieValidationResult = { valid: true }
mockValidate.mockResolvedValue(result)
const c = await loadComposable()
const r = await c.validateCookies('sess', 'remember')
expect(r.valid).toBe(true)
})
it('validateCookies returns valid false on error', async () => {
mockValidate.mockRejectedValue(new Error('Unauthorized'))
const c = await loadComposable()
const r = await c.validateCookies('sess', 'remember')
expect(r.valid).toBe(false)
})
})
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StorygraphActiveSyncStatus, StorygraphSettings, StorygraphSyncPendingSummary } from '@bookorbit/types'
vi.mock('../../api/storygraph.api', () => ({
startStorygraphSync: vi.fn<() => Promise<{ runId: number }>>(),
cancelStorygraphSync: vi.fn<() => Promise<void>>(),
fetchStorygraphSyncStatus: vi.fn<() => Promise<StorygraphActiveSyncStatus | null>>(),
fetchStorygraphSyncPendingSummary: vi.fn<() => Promise<StorygraphSyncPendingSummary>>(),
streamStorygraphSyncStatus: vi.fn<(onStatus: (s: StorygraphActiveSyncStatus | null) => void, signal?: AbortSignal) => Promise<void>>(),
fetchStorygraphSettings: vi.fn<() => Promise<StorygraphSettings>>(),
}))
import {
cancelStorygraphSync,
fetchStorygraphSettings,
fetchStorygraphSyncStatus,
fetchStorygraphSyncPendingSummary,
startStorygraphSync,
streamStorygraphSyncStatus,
} from '../../api/storygraph.api'
const mockStart = vi.mocked(startStorygraphSync)
const mockCancel = vi.mocked(cancelStorygraphSync)
const mockStatus = vi.mocked(fetchStorygraphSyncStatus)
const mockPendingSummary = vi.mocked(fetchStorygraphSyncPendingSummary)
const mockStream = vi.mocked(streamStorygraphSyncStatus)
const mockFetchSettings = vi.mocked(fetchStorygraphSettings)
const RUNNING_STATUS: StorygraphActiveSyncStatus = {
runId: 1,
status: 'running',
totalBooks: 10,
syncedBooks: 3,
skippedBooks: 0,
failedBooks: 0,
processedBooks: 3,
}
const SETTINGS: StorygraphSettings = {
cookiesConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
bookSyncMode: 'all_eligible',
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
lastSyncedAt: null,
}
async function loadComposable() {
const { useStorygraphSync } = await import('../useStorygraphSync')
return useStorygraphSync()
}
describe('useStorygraphSync', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
vi.useFakeTimers()
mockStatus.mockResolvedValue(null)
mockPendingSummary.mockResolvedValue({ totalBooks: 0, pendingBooks: 0 })
mockStream.mockResolvedValue(undefined)
mockFetchSettings.mockResolvedValue(SETTINGS)
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('startSync sets activeSyncStatus and starts stream', async () => {
mockStart.mockResolvedValue({ runId: 1 })
const c = await loadComposable()
await c.startSync()
expect(c.activeSyncStatus.value).toEqual({
runId: 1,
status: 'running',
syncedBooks: 0,
skippedBooks: 0,
failedBooks: 0,
processedBooks: 0,
totalBooks: 0,
})
expect(c.isSyncing.value).toBe(true)
c.stopSyncTracking()
})
it('startSync sets error on failure', async () => {
mockStart.mockRejectedValue(new Error('Server busy'))
const c = await loadComposable()
await c.startSync()
expect(c.error.value).toBe('Server busy')
})
it('startSync does not mark a run active when the server reports no run', async () => {
mockStart.mockResolvedValue({ runId: 0 })
const c = await loadComposable()
await c.startSync()
expect(c.activeSyncStatus.value).toBeNull()
expect(c.error.value).toBe('StoryGraph sync is not available right now')
expect(mockFetchSettings).toHaveBeenCalled()
})
it('cancelSync clears status and stops stream tracking', async () => {
mockCancel.mockResolvedValue(undefined)
const c = await loadComposable()
c.activeSyncStatus.value = RUNNING_STATUS
await c.cancelSync()
expect(c.activeSyncStatus.value).toBeNull()
})
it('fetchStatus starts stream if sync is running', async () => {
mockStatus.mockResolvedValue({ runId: 2, status: 'running', totalBooks: 10, syncedBooks: 3, skippedBooks: 0, failedBooks: 0, processedBooks: 3 })
const c = await loadComposable()
await c.fetchStatus()
expect(c.isSyncing.value).toBe(true)
expect(c.activeSyncStatus.value?.runId).toBe(2)
c.stopSyncTracking()
})
it('fetchStatus loads pending summary', async () => {
mockPendingSummary.mockResolvedValue({ totalBooks: 6, pendingBooks: 2 })
const c = await loadComposable()
await c.fetchStatus()
expect(c.pendingSummary.value).toEqual({ totalBooks: 6, pendingBooks: 2 })
})
it('syncProgress is 0 when no sync', async () => {
const c = await loadComposable()
expect(c.syncProgress.value).toBe(0)
})
it('syncProgress is calculated correctly', async () => {
const c = await loadComposable()
c.activeSyncStatus.value = RUNNING_STATUS
expect(c.syncProgress.value).toBe(30)
c.stopSyncTracking()
})
it('ignores stale stream responses after stream stops', async () => {
mockStart.mockResolvedValue({ runId: 1 })
let onStatusFromStream: ((status: StorygraphActiveSyncStatus | null) => void | Promise<void>) | null = null
mockStream.mockImplementationOnce(async (onStatus) => {
onStatusFromStream = onStatus
return new Promise<void>(() => {
// keep stream open
})
})
const c = await loadComposable()
await c.startSync()
expect(onStatusFromStream).not.toBeNull()
c.stopSyncTracking()
c.activeSyncStatus.value = null
const streamCallback = onStatusFromStream as ((status: StorygraphActiveSyncStatus | null) => void | Promise<void>) | null
if (streamCallback) {
await streamCallback(RUNNING_STATUS)
}
expect(c.activeSyncStatus.value).toBeNull()
})
})
@@ -0,0 +1,175 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { StorygraphBookSyncEffectiveReason, StorygraphBookSyncState } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { fetchStorygraphBookSyncState, startStorygraphBookSync, updateStorygraphBookSyncState } from '../api/storygraph.api'
import { useStorygraphSettings } from './useStorygraphSettings'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function resolveReasonLabel(reason: StorygraphBookSyncEffectiveReason | null | undefined): string | null {
switch (reason) {
case 'excluded':
return 'Excluded'
case 'not_selected':
return 'Not selected'
case 'unread':
return null
case 'unsupported_status':
return 'Unsupported'
case 'missing_cookies':
case 'permission_denied':
case 'user_disabled':
case 'global_disabled':
return 'Unavailable'
default:
return null
}
}
export function useStorygraphBookSyncState(bookId: Ref<number>) {
const { hasPermission } = usePermissions()
const { settings, fetchSettings } = useStorygraphSettings()
const state = ref<StorygraphBookSyncState | null>(null)
const settingsLoaded = ref(false)
const loading = ref(false)
const saving = ref(false)
const syncing = ref(false)
const error = ref<string | null>(null)
let settingsRequestId = 0
let stateRequestId = 0
let syncRequestId = 0
const canUseStorygraphSync = computed(() => hasPermission('storygraph_sync'))
const visible = computed(() => canUseStorygraphSync.value && settingsLoaded.value && settings.value?.effectiveEnabled === true)
const syncEnabled = computed(() => {
if (state.value?.syncEnabled) return true
if (state.value?.effectiveReason === 'unread') {
const bookSyncMode = settings.value?.bookSyncMode ?? 'all_eligible'
return bookSyncMode === 'all_eligible' ? state.value.syncOverride !== 'excluded' : state.value.syncOverride === 'included'
}
return false
})
const canSyncNow = computed(() => state.value?.canSyncNow === true)
const statusText = computed(() => {
if (error.value) return error.value
if (syncing.value) return 'Syncing...'
if (saving.value) return 'Saving...'
if (loading.value) return 'Loading...'
if (state.value?.syncError) return `Error: ${state.value.syncError}`
if (state.value?.effectiveReason === 'unread' && syncEnabled.value) return 'Will sync when reading starts'
if (!syncEnabled.value) return resolveReasonLabel(state.value?.effectiveReason) ?? 'Excluded'
if (canSyncNow.value) return 'Pending sync'
if (state.value?.lastSyncedAt) return `Synced ${formatDateTime(state.value.lastSyncedAt)}`
return 'Included'
})
const statusClass = computed(() => (error.value || state.value?.syncError ? 'text-destructive' : 'text-muted-foreground'))
const disabled = computed(() => loading.value || saving.value || syncing.value)
watch(
canUseStorygraphSync,
async (allowed) => {
const requestId = ++settingsRequestId
settingsLoaded.value = false
if (!allowed) return
await fetchSettings()
if (requestId === settingsRequestId) settingsLoaded.value = true
},
{ immediate: true },
)
// bookSyncMode is a fetch source too: when the global scope flips, a mounted item's
// effectiveReason/toggle would otherwise keep showing state computed under the old mode.
watch(
[bookId, visible, () => settings.value?.bookSyncMode],
async ([currentBookId, isVisible]) => {
const requestId = ++stateRequestId
state.value = null
error.value = null
if (!isVisible) {
loading.value = false
return
}
loading.value = true
try {
const nextState = await fetchStorygraphBookSyncState(currentBookId)
if (requestId === stateRequestId) state.value = nextState
} catch {
if (requestId === stateRequestId) error.value = 'Unable to load StoryGraph sync state.'
} finally {
if (requestId === stateRequestId) loading.value = false
}
},
{ immediate: true },
)
async function setSyncEnabled(syncEnabledValue: boolean): Promise<void> {
if (!visible.value || saving.value) return
const targetBookId = bookId.value
const previous = state.value
const bookSyncMode = settings.value?.bookSyncMode ?? 'all_eligible'
const effectiveReason = syncEnabledValue ? null : bookSyncMode === 'selected_only' ? 'not_selected' : 'excluded'
state.value = {
bookId: targetBookId,
syncOverride: syncEnabledValue ? (bookSyncMode === 'selected_only' ? 'included' : null) : bookSyncMode === 'selected_only' ? null : 'excluded',
syncEnabled: syncEnabledValue,
canSyncNow: syncEnabledValue,
effectiveReason,
lastSyncedAt: previous?.lastSyncedAt ?? null,
syncError: previous?.syncError ?? null,
}
error.value = null
saving.value = true
try {
const nextState = await updateStorygraphBookSyncState(targetBookId, { syncEnabled: syncEnabledValue })
if (bookId.value === targetBookId) state.value = nextState
} catch {
if (bookId.value === targetBookId) {
state.value = previous
error.value = 'Failed to save StoryGraph sync setting.'
}
} finally {
saving.value = false
}
}
async function syncNow(): Promise<void> {
if (!visible.value || syncing.value || !canSyncNow.value) return
const targetBookId = bookId.value
const requestId = ++syncRequestId
syncing.value = true
error.value = null
try {
const result = await startStorygraphBookSync(targetBookId)
if (requestId === syncRequestId && bookId.value === targetBookId) {
state.value = result.state
}
} catch {
if (requestId === syncRequestId && bookId.value === targetBookId) {
error.value = 'Failed to sync StoryGraph book.'
}
} finally {
if (requestId === syncRequestId) syncing.value = false
}
}
return {
visible,
syncEnabled,
canSyncNow,
statusText,
statusClass,
disabled,
syncNow,
setSyncEnabled,
}
}
@@ -0,0 +1,74 @@
import { ref } from 'vue'
import type { StorygraphSettings, UpsertStorygraphSettingsPayload } from '@bookorbit/types'
import { disconnectStorygraph, fetchStorygraphSettings, upsertStorygraphSettings, validateStorygraphCookies } from '../api/storygraph.api'
const settings = ref<StorygraphSettings | null>(null)
const loading = ref(false)
const saving = ref(false)
const validating = ref(false)
const error = ref<string | null>(null)
export function useStorygraphSettings() {
async function fetchSettings(): Promise<void> {
loading.value = true
error.value = null
try {
settings.value = await fetchStorygraphSettings()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load settings'
} finally {
loading.value = false
}
}
async function saveSettings(payload: UpsertStorygraphSettingsPayload): Promise<boolean> {
saving.value = true
error.value = null
try {
settings.value = await upsertStorygraphSettings(payload)
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to save settings'
return false
} finally {
saving.value = false
}
}
async function disconnect(): Promise<void> {
saving.value = true
error.value = null
try {
await disconnectStorygraph()
settings.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to disconnect'
} finally {
saving.value = false
}
}
async function validateCookies(sessionCookie?: string, rememberToken?: string): Promise<{ valid: boolean }> {
validating.value = true
try {
const result = await validateStorygraphCookies(sessionCookie, rememberToken)
return { valid: result.valid }
} catch {
return { valid: false }
} finally {
validating.value = false
}
}
return {
settings,
loading,
saving,
validating,
error,
fetchSettings,
saveSettings,
disconnect,
validateCookies,
}
}
@@ -0,0 +1,153 @@
import { computed, ref } from 'vue'
import type { StorygraphActiveSyncStatus, StorygraphSyncFailure, StorygraphSyncPendingSummary } from '@bookorbit/types'
import {
cancelStorygraphSync,
fetchStorygraphSyncFailures,
fetchStorygraphSyncPendingSummary,
fetchStorygraphSyncStatus,
startStorygraphSync,
streamStorygraphSyncStatus,
} from '../api/storygraph.api'
import { useStorygraphSettings } from './useStorygraphSettings'
const activeSyncStatus = ref<StorygraphActiveSyncStatus | null>(null)
const lastRunSummary = ref<StorygraphActiveSyncStatus | null>(null)
const syncFailures = ref<StorygraphSyncFailure[]>([])
const pendingSummary = ref<StorygraphSyncPendingSummary>({ totalBooks: 0, pendingBooks: 0 })
const syncing = ref(false)
const loadingPending = ref(true)
const error = ref<string | null>(null)
let streamAbortController: AbortController | null = null
let streamGeneration = 0
const isSyncing = computed(() => activeSyncStatus.value?.status === 'running')
const syncProgress = computed(() => {
const s = activeSyncStatus.value
if (!s || s.totalBooks === 0) return 0
return Math.round((s.processedBooks / s.totalBooks) * 100)
})
export function useStorygraphSync() {
function startSyncTracking(): void {
stopSyncTracking()
const generation = ++streamGeneration
const controller = new AbortController()
streamAbortController = controller
void streamStorygraphSyncStatus(async (status) => {
if (generation !== streamGeneration) return
if (status && status.status !== 'running') {
lastRunSummary.value = status
}
activeSyncStatus.value = status
if (status?.status !== 'running') {
stopSyncTracking()
await Promise.all([fetchPendingSummary(), fetchSyncFailures()])
await useStorygraphSettings().fetchSettings()
}
}, controller.signal)
.then(async () => {
if (generation !== streamGeneration) return
await fetchPendingSummary()
await useStorygraphSettings().fetchSettings()
})
.catch(async () => {
if (generation !== streamGeneration) return
if (controller.signal.aborted) return
await fetchPendingSummary()
if (activeSyncStatus.value?.status === 'running') {
startSyncTracking()
}
})
}
function stopSyncTracking(): void {
streamGeneration++
if (streamAbortController !== null) {
streamAbortController.abort()
streamAbortController = null
}
}
async function startSync(): Promise<void> {
syncing.value = true
error.value = null
lastRunSummary.value = null
try {
const { runId } = await startStorygraphSync()
if (runId <= 0) {
error.value = 'StoryGraph sync is not available right now'
await Promise.all([fetchPendingSummary(), useStorygraphSettings().fetchSettings()])
return
}
activeSyncStatus.value = {
runId,
status: 'running',
syncedBooks: 0,
skippedBooks: 0,
failedBooks: 0,
processedBooks: 0,
totalBooks: 0,
}
startSyncTracking()
await fetchPendingSummary()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to start sync'
} finally {
syncing.value = false
}
}
async function cancelSync(): Promise<void> {
await cancelStorygraphSync().catch(() => null)
stopSyncTracking()
activeSyncStatus.value = null
await fetchPendingSummary()
}
async function fetchStatus(): Promise<void> {
const [status] = await Promise.all([fetchStorygraphSyncStatus(), fetchPendingSummary(), fetchSyncFailures()])
activeSyncStatus.value = status
if (status?.status === 'running') {
startSyncTracking()
}
}
async function fetchSyncFailures(): Promise<void> {
try {
syncFailures.value = await fetchStorygraphSyncFailures()
} catch {
// silent: the failure list is supplementary and must not break the sync card
}
}
async function fetchPendingSummary(): Promise<void> {
loadingPending.value = true
try {
pendingSummary.value = await fetchStorygraphSyncPendingSummary()
} catch {
// silent
} finally {
loadingPending.value = false
}
}
return {
activeSyncStatus,
lastRunSummary,
syncFailures,
pendingSummary,
syncing,
loadingPending,
error,
isSyncing,
syncProgress,
startSync,
cancelSync,
fetchStatus,
fetchPendingSummary,
fetchSyncFailures,
stopSyncTracking,
}
}
+8
View File
@@ -74,6 +74,8 @@ function resolveLegacyIntegrationsRoute(tab: unknown): string {
return 'settings-hardcover'
case 'readwise':
return 'settings-readwise'
case 'storygraph':
return 'settings-storygraph'
case 'kobo':
default:
return 'settings-kobo'
@@ -154,6 +156,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/features/readwise/components/ReadwiseSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'Readwise' },
},
{
path: 'storygraph',
name: 'settings-storygraph',
component: () => import('@/features/storygraph/components/StorygraphSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'StoryGraph' },
},
{
path: 'email',
name: 'settings-email',