mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 → 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>
|
||||
+482
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user