mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(kobo): add device sync history tracking
Records success and failure events for Kobo device syncs in the database, including operation duration, event types, and error details. Surfaces the recent sync history in the Kobo Settings UI for users to monitor their device activity.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { KoboSyncHistoryEntry } from '@bookorbit/types'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}))
|
||||
|
||||
function makeHistoryEntry(overrides: Partial<KoboSyncHistoryEntry> = {}): KoboSyncHistoryEntry {
|
||||
return {
|
||||
id: 1,
|
||||
deviceId: 2,
|
||||
deviceName: 'Kobo Libra',
|
||||
event: 'library_sync',
|
||||
status: 'success',
|
||||
counts: { entitlements: 3, hasMore: false },
|
||||
durationMs: 25,
|
||||
errorClass: null,
|
||||
error: null,
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(data: unknown, options: { ok?: boolean; status?: number } = {}): Response {
|
||||
const { ok = true, status = ok ? 200 : 500 } = options
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useKoboSyncHistory', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
it('fetchHistory loads recent Kobo sync history', async () => {
|
||||
const history = [makeHistoryEntry()]
|
||||
apiMock.mockResolvedValueOnce(makeResponse(history))
|
||||
|
||||
const { useKoboSyncHistory } = await import('../useKoboSyncHistory')
|
||||
const { history: loadedHistory, loading, fetchHistory } = useKoboSyncHistory()
|
||||
|
||||
await fetchHistory()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/kobo/history?limit=20')
|
||||
expect(loadedHistory.value).toEqual(history)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets loading while fetchHistory is in flight', async () => {
|
||||
let resolveResponse: ((value: Response) => void) | undefined
|
||||
apiMock.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { useKoboSyncHistory } = await import('../useKoboSyncHistory')
|
||||
const { loading, fetchHistory } = useKoboSyncHistory()
|
||||
|
||||
const promise = fetchHistory()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveResponse?.(makeResponse([makeHistoryEntry()]))
|
||||
await promise
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent fetches', async () => {
|
||||
apiMock.mockResolvedValueOnce(makeResponse([makeHistoryEntry({ id: 2 })]))
|
||||
|
||||
const { useKoboSyncHistory } = await import('../useKoboSyncHistory')
|
||||
const { fetchHistory } = useKoboSyncHistory()
|
||||
|
||||
await Promise.all([fetchHistory(), fetchHistory()])
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws a fallback error when the API request fails', async () => {
|
||||
apiMock.mockResolvedValueOnce(makeResponse({ message: 'nope' }, { ok: false, status: 500 }))
|
||||
|
||||
const { useKoboSyncHistory } = await import('../useKoboSyncHistory')
|
||||
const { loading, fetchHistory } = useKoboSyncHistory()
|
||||
|
||||
await expect(fetchHistory()).rejects.toThrow('Failed to fetch Kobo sync history')
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { KoboSyncHistoryEntry } from '@bookorbit/types'
|
||||
|
||||
const history = ref<KoboSyncHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
|
||||
export function useKoboSyncHistory() {
|
||||
async function fetchHistory(limit = 20): Promise<void> {
|
||||
if (fetchPromise) return fetchPromise
|
||||
loading.value = true
|
||||
fetchPromise = api(`/api/v1/kobo/history?limit=${limit}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch Kobo sync history')
|
||||
history.value = await res.json()
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
fetchPromise = null
|
||||
})
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
return { history, loading, fetchHistory }
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, Copy, Check, Pencil, X, Tablet } from '@lucide/vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Plus, Trash2, Copy, Check, Pencil, X, Tablet, RefreshCw, History, Download, Upload, Bookmark } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { copyToClipboard } from '@/lib/clipboard'
|
||||
import { useKoboDevices } from '@/features/kobo/composables/useKoboDevices'
|
||||
import { useKoboSettings } from '@/features/kobo/composables/useKoboSettings'
|
||||
import type { KoboDevice } from '@bookorbit/types'
|
||||
import { useKoboSyncHistory } from '@/features/kobo/composables/useKoboSyncHistory'
|
||||
import type { KoboDevice, KoboSyncHistoryEntry } from '@bookorbit/types'
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const { devices, fetchDevices, createDevice, renameDevice, revokeDevice } = useKoboDevices()
|
||||
const { settings, fetchSettings, updateSettings } = useKoboSettings()
|
||||
const { history: syncHistory, loading: historyLoading, fetchHistory } = useKoboSyncHistory()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -42,6 +45,61 @@ const twoWayProgressSync = ref(false)
|
||||
const syncBookOrbitAnnotationsToKobo = ref(false)
|
||||
const savingSettings = ref(false)
|
||||
const settingsError = ref<string | null>(null)
|
||||
const refreshingHistory = ref(false)
|
||||
const historyFilter = ref<'all' | 'failures'>('all')
|
||||
const currentLimit = ref(20)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
type Tab = 'settings' | 'activity'
|
||||
type HistoryDisplayItem = {
|
||||
key: string
|
||||
primary: KoboSyncHistoryEntry
|
||||
entries: KoboSyncHistoryEntry[]
|
||||
}
|
||||
|
||||
const progressGroupWindowMs = 5 * 60 * 1000
|
||||
const activeTab = ref<Tab>((route.query.tab as Tab) === 'activity' ? 'activity' : 'settings')
|
||||
|
||||
function selectTab(tab: Tab) {
|
||||
activeTab.value = tab
|
||||
router.replace({ name: 'settings-kobo', query: { ...route.query, tab } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(value) => {
|
||||
activeTab.value = (value as Tab) === 'activity' ? 'activity' : 'settings'
|
||||
},
|
||||
)
|
||||
|
||||
const hasSyncHistory = computed(() => syncHistory.value.length > 0)
|
||||
const latestHistoryEntry = computed(() => syncHistory.value[0] ?? null)
|
||||
const latestSuccessfulActivity = computed(() => syncHistory.value.find((entry) => entry.status === 'success') ?? null)
|
||||
const latestFailedActivity = computed(() => syncHistory.value.find((entry) => entry.status === 'failed') ?? null)
|
||||
const recentFailureCount = computed(() => syncHistory.value.filter((entry) => entry.status === 'failed').length)
|
||||
const filteredSyncHistory = computed(() =>
|
||||
historyFilter.value === 'failures' ? syncHistory.value.filter((entry) => entry.status === 'failed') : syncHistory.value,
|
||||
)
|
||||
const displayedSyncHistory = computed(() => buildHistoryDisplayItems(filteredSyncHistory.value))
|
||||
const syncHealthLabel = computed(() => {
|
||||
if (!latestHistoryEntry.value) return 'Waiting for activity'
|
||||
return latestHistoryEntry.value.status === 'failed' ? 'Needs attention' : 'Sync healthy'
|
||||
})
|
||||
const latestSuccessLabel = computed(() => (latestSuccessfulActivity.value ? formatLastSeen(latestSuccessfulActivity.value.createdAt) : 'Never'))
|
||||
const latestFailureLabel = computed(() => (latestFailedActivity.value ? formatLastSeen(latestFailedActivity.value.createdAt) : 'None'))
|
||||
const recentFailureLabel = computed(() => pluralize(recentFailureCount.value, 'failure'))
|
||||
const historyHeaderDetail = computed(() => {
|
||||
if (!latestHistoryEntry.value) return 'No Kobo sync activity has been recorded.'
|
||||
const parts = [syncHealthLabel.value, `Last success ${latestSuccessLabel.value}`]
|
||||
if (latestFailedActivity.value) parts.push(`Last failure ${latestFailureLabel.value}`)
|
||||
if (recentFailureCount.value > 0) parts.push(recentFailureLabel.value)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const filteredHistoryEmptyLabel = computed(() =>
|
||||
historyFilter.value === 'failures' ? 'No failed Kobo activity in recent history.' : 'No Kobo activity yet.',
|
||||
)
|
||||
|
||||
function applySettingsToLocal() {
|
||||
readingThreshold.value = settings.value.readingThreshold
|
||||
@@ -67,9 +125,341 @@ function formatLastSeen(date: string | null): string {
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
function formatDateTime(date: string | null | undefined): string {
|
||||
if (!date) return 'Unknown'
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(date))
|
||||
}
|
||||
|
||||
function buildHistoryDisplayItems(entries: KoboSyncHistoryEntry[]): HistoryDisplayItem[] {
|
||||
const consumed = new Set<number>()
|
||||
const items: HistoryDisplayItem[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (consumed.has(entry.id)) continue
|
||||
if (!canGroupProgressEntry(entry)) {
|
||||
items.push({ key: `entry-${entry.id}`, primary: entry, entries: [entry] })
|
||||
continue
|
||||
}
|
||||
|
||||
const group = entries.filter((candidate) => (candidate.id === entry.id || !consumed.has(candidate.id)) && canMergeProgressEntry(entry, candidate))
|
||||
if (group.length <= 1) {
|
||||
items.push({ key: `entry-${entry.id}`, primary: entry, entries: [entry] })
|
||||
continue
|
||||
}
|
||||
|
||||
for (const candidate of group) consumed.add(candidate.id)
|
||||
items.push({ key: `progress-${entry.deviceId ?? 'removed'}-${historyBookKey(entry)}-${entry.id}`, primary: entry, entries: group })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function canGroupProgressEntry(entry: KoboSyncHistoryEntry): boolean {
|
||||
return entry.status === 'success' && entry.event === 'progress_update'
|
||||
}
|
||||
|
||||
function canMergeProgressEntry(anchor: KoboSyncHistoryEntry, candidate: KoboSyncHistoryEntry): boolean {
|
||||
if (!canGroupProgressEntry(candidate)) return false
|
||||
if (anchor.deviceId !== candidate.deviceId) return false
|
||||
if (historyBookKey(anchor) !== historyBookKey(candidate)) return false
|
||||
if (historyCountBoolean(anchor, 'twoWayProgressSync') !== historyCountBoolean(candidate, 'twoWayProgressSync')) return false
|
||||
|
||||
const anchorTime = new Date(anchor.createdAt).getTime()
|
||||
const candidateTime = new Date(candidate.createdAt).getTime()
|
||||
return Number.isFinite(anchorTime) && Number.isFinite(candidateTime) && Math.abs(anchorTime - candidateTime) <= progressGroupWindowMs
|
||||
}
|
||||
|
||||
function formatHistoryEvent(event: KoboSyncHistoryEntry['event']): string {
|
||||
const labels: Record<KoboSyncHistoryEntry['event'], string> = {
|
||||
library_sync: 'Library checked for updates',
|
||||
book_download: 'Book downloaded',
|
||||
progress_update: 'Reading position updated',
|
||||
annotations_pull: 'Highlights sent to Kobo',
|
||||
annotations_push: 'Highlights received from Kobo',
|
||||
}
|
||||
return labels[event]
|
||||
}
|
||||
|
||||
function pluralize(value: number, singular: string, plural = `${singular}s`): string {
|
||||
return `${value} ${value === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
function historyCountNumber(entry: KoboSyncHistoryEntry, key: string): number {
|
||||
const value = entry.counts[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function historyCountBoolean(entry: KoboSyncHistoryEntry, key: string): boolean {
|
||||
return entry.counts[key] === true
|
||||
}
|
||||
|
||||
function historyCountString(entry: KoboSyncHistoryEntry, key: string): string | null {
|
||||
const value = entry.counts[key]
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
function historyBookTitle(entry: KoboSyncHistoryEntry): string | null {
|
||||
return historyCountString(entry, 'bookTitle')
|
||||
}
|
||||
|
||||
function historyBookKey(entry: KoboSyncHistoryEntry): string {
|
||||
const bookId = entry.counts.bookId
|
||||
if (typeof bookId === 'number' && Number.isFinite(bookId)) return String(bookId)
|
||||
const title = historyBookTitle(entry)
|
||||
return title ?? 'unknown'
|
||||
}
|
||||
|
||||
function isHistoryNoOp(entry: KoboSyncHistoryEntry): boolean {
|
||||
if (entry.status !== 'success') return false
|
||||
switch (entry.event) {
|
||||
case 'library_sync':
|
||||
return historyCountNumber(entry, 'entitlements') === 0
|
||||
case 'book_download':
|
||||
return historyCountNumber(entry, 'downloads') === 0
|
||||
case 'progress_update':
|
||||
return false
|
||||
case 'annotations_pull':
|
||||
return historyCountNumber(entry, 'served') === 0
|
||||
case 'annotations_push':
|
||||
return historyCountNumber(entry, 'created') + historyCountNumber(entry, 'updated') === 0
|
||||
}
|
||||
}
|
||||
|
||||
function historyOutcomeText(entry: KoboSyncHistoryEntry): string {
|
||||
if (entry.status === 'failed') return 'Failed'
|
||||
|
||||
if (isHistoryNoOp(entry)) {
|
||||
switch (entry.event) {
|
||||
case 'library_sync':
|
||||
return 'No updates'
|
||||
case 'annotations_pull':
|
||||
case 'annotations_push':
|
||||
return 'No changes'
|
||||
}
|
||||
}
|
||||
|
||||
switch (entry.event) {
|
||||
case 'library_sync':
|
||||
return historyCountBoolean(entry, 'hasMore') ? 'Pending' : 'Completed'
|
||||
case 'book_download':
|
||||
return 'Downloaded'
|
||||
case 'progress_update':
|
||||
return 'Updated'
|
||||
case 'annotations_pull':
|
||||
return 'Sent'
|
||||
case 'annotations_push':
|
||||
return 'Imported'
|
||||
}
|
||||
}
|
||||
|
||||
function historyFailureSummary(entry: KoboSyncHistoryEntry): string {
|
||||
switch (entry.event) {
|
||||
case 'library_sync':
|
||||
return 'Library check did not complete'
|
||||
case 'book_download':
|
||||
return 'Download did not complete'
|
||||
case 'progress_update':
|
||||
return 'Reading position did not update'
|
||||
case 'annotations_pull':
|
||||
return 'Highlights were not sent'
|
||||
case 'annotations_push':
|
||||
return 'Highlights were not imported'
|
||||
}
|
||||
}
|
||||
|
||||
function historyDetailChips(entry: KoboSyncHistoryEntry): string[] {
|
||||
switch (entry.event) {
|
||||
case 'library_sync': {
|
||||
return [historyCountBoolean(entry, 'hasMore') ? 'More pending' : 'No updates pending']
|
||||
}
|
||||
case 'book_download':
|
||||
return [pluralize(historyCountNumber(entry, 'downloads') || 1, 'book')]
|
||||
case 'progress_update':
|
||||
return [progressSyncModeText(entry)]
|
||||
case 'annotations_pull': {
|
||||
const labels: string[] = []
|
||||
const tombstones = historyCountNumber(entry, 'tombstones')
|
||||
if (tombstones > 0) labels.push(`${tombstones} deleted annotations`)
|
||||
labels.push(historyCountBoolean(entry, 'notModified') ? 'Device already current' : 'Fresh highlight data')
|
||||
return labels
|
||||
}
|
||||
case 'annotations_push': {
|
||||
const labels: string[] = []
|
||||
const created = historyCountNumber(entry, 'created')
|
||||
const updated = historyCountNumber(entry, 'updated')
|
||||
const deleted = historyCountNumber(entry, 'deleted')
|
||||
const unchanged = historyCountNumber(entry, 'unchanged')
|
||||
if (created > 0) labels.push(`${created} created`)
|
||||
if (updated > 0) labels.push(`${updated} updated`)
|
||||
if (deleted > 0) labels.push(`${deleted} deleted`)
|
||||
if (unchanged > 0) labels.push(`${unchanged} unchanged`)
|
||||
return labels.length > 0 ? labels : ['No changes']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function historyDetailText(entry: KoboSyncHistoryEntry): string {
|
||||
return historyDetailChips(entry).join(' · ')
|
||||
}
|
||||
|
||||
function isGroupedHistoryItem(item: HistoryDisplayItem): boolean {
|
||||
return item.entries.length > 1
|
||||
}
|
||||
|
||||
function historyItemTitle(item: HistoryDisplayItem): string {
|
||||
const entry = item.primary
|
||||
const title = historyBookTitle(entry)
|
||||
if (isGroupedHistoryItem(item)) {
|
||||
return title ? `Reading position synced: ${title}` : 'Reading position synced'
|
||||
}
|
||||
|
||||
const label = formatHistoryEvent(entry.event)
|
||||
if (!title || entry.event === 'library_sync') return label
|
||||
return `${label}: ${title}`
|
||||
}
|
||||
|
||||
function historyItemOutcomeText(item: HistoryDisplayItem): string {
|
||||
return isGroupedHistoryItem(item) ? `${pluralize(item.entries.length, 'update')}` : historyOutcomeText(item.primary)
|
||||
}
|
||||
|
||||
function historyDirectionText(entry: KoboSyncHistoryEntry): string {
|
||||
switch (entry.event) {
|
||||
case 'library_sync':
|
||||
case 'book_download':
|
||||
case 'annotations_pull':
|
||||
return 'BookOrbit -> Kobo'
|
||||
case 'progress_update':
|
||||
case 'annotations_push':
|
||||
return 'Kobo -> BookOrbit'
|
||||
}
|
||||
}
|
||||
|
||||
function progressSyncModeText(entry: KoboSyncHistoryEntry): string {
|
||||
return historyCountBoolean(entry, 'twoWayProgressSync') ? 'Two-way sync enabled' : 'Device progress saved'
|
||||
}
|
||||
|
||||
function historyItemSummary(item: HistoryDisplayItem): string {
|
||||
const entry = item.primary
|
||||
if (entry.status === 'failed') return historyFailureSummary(entry)
|
||||
|
||||
const title = historyBookTitle(entry)
|
||||
switch (entry.event) {
|
||||
case 'library_sync': {
|
||||
const updates = historyCountNumber(entry, 'entitlements')
|
||||
if (updates === 0) return 'No library updates are pending for this device'
|
||||
return `${pluralize(updates, 'library update')} prepared for the device`
|
||||
}
|
||||
case 'book_download':
|
||||
return title
|
||||
? `${title} was downloaded by ${historyDeviceName(entry)}`
|
||||
: `${pluralize(historyCountNumber(entry, 'downloads') || 1, 'book')} downloaded`
|
||||
case 'progress_update':
|
||||
if (isGroupedHistoryItem(item)) {
|
||||
return title
|
||||
? `${pluralize(item.entries.length, 'progress update')} synced for ${title}`
|
||||
: `${pluralize(item.entries.length, 'progress update')} synced`
|
||||
}
|
||||
return title ? `Kobo reported a new reading position for ${title}` : 'Kobo reported a new reading position'
|
||||
case 'annotations_pull': {
|
||||
const served = historyCountNumber(entry, 'served')
|
||||
if (served === 0) return title ? `No highlight changes needed for ${title}` : 'No highlight changes needed'
|
||||
return title ? `${pluralize(served, 'highlight')} sent to Kobo for ${title}` : `${pluralize(served, 'highlight')} sent to Kobo`
|
||||
}
|
||||
case 'annotations_push': {
|
||||
const changed = historyCountNumber(entry, 'created') + historyCountNumber(entry, 'updated') + historyCountNumber(entry, 'deleted')
|
||||
if (changed === 0) return title ? `No Kobo highlight changes for ${title}` : 'No Kobo highlight changes'
|
||||
return title ? `${pluralize(changed, 'highlight')} imported from Kobo for ${title}` : `${pluralize(changed, 'highlight')} imported from Kobo`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function historyItemSecondaryText(item: HistoryDisplayItem): string {
|
||||
const entry = item.primary
|
||||
const parts = [historyDirectionText(entry)]
|
||||
if (entry.event === 'progress_update') {
|
||||
parts.push(progressSyncModeText(entry))
|
||||
} else {
|
||||
const detail = historyDetailText(entry)
|
||||
if (detail) parts.push(detail)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function historyItemTimeTitle(item: HistoryDisplayItem): string {
|
||||
if (!isGroupedHistoryItem(item)) return formatDateTime(item.primary.createdAt)
|
||||
const oldest = item.entries[item.entries.length - 1]
|
||||
return `${formatDateTime(oldest?.createdAt)} to ${formatDateTime(item.primary.createdAt)}`
|
||||
}
|
||||
|
||||
function historyItemGroupedLabel(item: HistoryDisplayItem): string {
|
||||
return isGroupedHistoryItem(item) ? `${pluralize(item.entries.length, 'event')} grouped` : ''
|
||||
}
|
||||
|
||||
function historyDeviceName(entry: KoboSyncHistoryEntry): string {
|
||||
return entry.deviceName ?? 'Removed device'
|
||||
}
|
||||
|
||||
function historyOutcomeClass(entry: KoboSyncHistoryEntry): string {
|
||||
if (entry.status === 'failed') {
|
||||
return 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
}
|
||||
if (isHistoryNoOp(entry)) {
|
||||
return 'border-border bg-muted/60 text-muted-foreground'
|
||||
}
|
||||
return 'border-primary/30 bg-primary/10 text-primary'
|
||||
}
|
||||
|
||||
function historyStatusDotClass(entry: KoboSyncHistoryEntry): string {
|
||||
return entry.status === 'success' ? 'bg-primary' : 'bg-destructive'
|
||||
}
|
||||
|
||||
function getHistoryEventIcon(event: KoboSyncHistoryEntry['event']) {
|
||||
switch (event) {
|
||||
case 'library_sync':
|
||||
return RefreshCw
|
||||
case 'book_download':
|
||||
return Download
|
||||
case 'progress_update':
|
||||
return Bookmark
|
||||
case 'annotations_pull':
|
||||
return Upload
|
||||
case 'annotations_push':
|
||||
return Download
|
||||
}
|
||||
}
|
||||
|
||||
function showAllHistory() {
|
||||
historyFilter.value = 'all'
|
||||
}
|
||||
|
||||
function showFailedHistory() {
|
||||
historyFilter.value = 'failures'
|
||||
}
|
||||
|
||||
function handleSelectSettingsTab() {
|
||||
selectTab('settings')
|
||||
}
|
||||
|
||||
function handleSelectActivityTab() {
|
||||
selectTab('activity')
|
||||
}
|
||||
|
||||
async function loadMoreHistory() {
|
||||
if (loadingMore.value || historyLoading.value) return
|
||||
loadingMore.value = true
|
||||
currentLimit.value = Math.min(currentLimit.value + 20, 100)
|
||||
try {
|
||||
await fetchHistory(currentLimit.value)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to load more history')
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([fetchDevices(), fetchSettings()])
|
||||
await Promise.all([fetchDevices(), fetchSettings(), fetchHistory(currentLimit.value)])
|
||||
applySettingsToLocal()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load'
|
||||
@@ -160,6 +550,18 @@ async function revoke(device: KoboDevice) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHistory() {
|
||||
refreshingHistory.value = true
|
||||
try {
|
||||
await fetchHistory(currentLimit.value)
|
||||
toast.success('Kobo sync history refreshed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to refresh history')
|
||||
} finally {
|
||||
refreshingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (readingThreshold.value >= finishedThreshold.value) {
|
||||
settingsError.value = 'Reading threshold must be less than finished threshold'
|
||||
@@ -195,120 +597,311 @@ async function saveSettings() {
|
||||
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
|
||||
<div v-else-if="error" class="text-sm text-destructive">{{ error }}</div>
|
||||
<template v-else>
|
||||
<!-- New device token display -->
|
||||
<div v-if="newDeviceSyncUrl" class="mb-8 border-2 border-primary/30 rounded-lg p-4 bg-primary/5 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
|
||||
<Check :size="13" stroke-width="3" />
|
||||
<!-- Tab switcher (not shown when embedded) -->
|
||||
<div
|
||||
v-if="!props.embedded"
|
||||
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
|
||||
:class="
|
||||
activeTab === 'settings'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
"
|
||||
@click="handleSelectSettingsTab"
|
||||
>
|
||||
Sync Settings
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
|
||||
:class="
|
||||
activeTab === 'activity'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
"
|
||||
@click="handleSelectActivityTab"
|
||||
>
|
||||
Activity Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'settings' || props.embedded">
|
||||
<!-- New device token display -->
|
||||
<div v-if="newDeviceSyncUrl" class="mb-8 border-2 border-primary/30 rounded-lg p-4 bg-primary/5 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
|
||||
<Check :size="13" stroke-width="3" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="settings-label leading-none mb-0.5">Device paired successfully</p>
|
||||
<p class="settings-hint">You're ready to set up your Kobo. Follow the instructions below.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="settings-label leading-none mb-0.5">Device paired successfully</p>
|
||||
<p class="settings-hint">You're ready to set up your Kobo. Follow the instructions below.</p>
|
||||
<button @click="dismissToken()" class="text-muted-foreground hover:text-foreground transition-colors p-1 shrink-0">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-background rounded-lg border border-border p-4">
|
||||
<p class="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-2.5">Sync URL</p>
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 rounded-md border border-border bg-muted/30">
|
||||
<Tablet :size="14" class="text-muted-foreground shrink-0" />
|
||||
<span class="flex-1 text-sm text-foreground font-mono select-all truncate min-w-0">{{ newDeviceSyncUrl }}</span>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
|
||||
@click="copyToken()"
|
||||
>
|
||||
<Copy :size="12" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 rounded-md border border-amber-200 dark:border-amber-900/50"
|
||||
>
|
||||
<X :size="14" class="shrink-0" />
|
||||
This URL will not be shown again. Keep it private.
|
||||
</div>
|
||||
</div>
|
||||
<button @click="dismissToken()" class="text-muted-foreground hover:text-foreground transition-colors p-1 shrink-0">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-background rounded-lg border border-border p-4">
|
||||
<p class="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-2.5">Sync URL</p>
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 rounded-md border border-border bg-muted/30">
|
||||
<Tablet :size="14" class="text-muted-foreground shrink-0" />
|
||||
<span class="flex-1 text-sm text-foreground font-mono select-all truncate min-w-0">{{ newDeviceSyncUrl }}</span>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
|
||||
@click="copyToken()"
|
||||
>
|
||||
<Copy :size="12" />
|
||||
Copy
|
||||
<!-- Devices -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="settings-group-label mb-0">Registered Devices</p>
|
||||
<button v-if="!showCreateForm" class="settings-btn-primary" @click="showCreateForm = true">
|
||||
<Plus :size="12" />
|
||||
Add device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<div v-if="showCreateForm" class="border border-border rounded-lg p-5 bg-card mb-4 space-y-4 shadow-xs">
|
||||
<div>
|
||||
<label class="settings-label block mb-1.5">Device name</label>
|
||||
<input v-model="newDeviceName" type="text" placeholder="e.g. My Kobo Libra" autofocus class="input-field w-full" />
|
||||
</div>
|
||||
<div v-if="createError" class="text-xs text-destructive">{{ createError }}</div>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button class="settings-btn-primary" :disabled="creating || !newDeviceName.trim()" @click="submitCreate()">
|
||||
{{ creating ? 'Creating...' : 'Create device' }}
|
||||
</button>
|
||||
<button class="settings-btn-outline" @click="cancelCreate()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 rounded-md border border-amber-200 dark:border-amber-900/50"
|
||||
>
|
||||
<X :size="14" class="shrink-0" />
|
||||
This URL will not be shown again. Keep it private.
|
||||
|
||||
<div v-if="devices.length === 0 && !showCreateForm" class="border border-border rounded-lg px-5 py-10 bg-card text-center shadow-xs">
|
||||
<div class="w-10 h-10 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<Tablet :size="18" class="text-muted-foreground/70" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-foreground">No devices yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1 max-w-[240px] mx-auto">Add a device to start syncing your books to your Kobo.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="devices.length > 0" class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||
<div v-for="device in devices" :key="device.id" class="px-5 py-4 bg-card transition-colors hover:bg-muted/30">
|
||||
<div v-if="renamingId === device.id" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
class="flex-1 input-field"
|
||||
@keydown.enter="submitRename(device)"
|
||||
@keydown.esc="cancelRename()"
|
||||
/>
|
||||
<button class="settings-btn-primary" :disabled="renaming || !renameValue.trim()" @click="submitRename(device)">Save</button>
|
||||
<button class="settings-btn-outline h-9 w-9 p-0 flex items-center justify-center" @click="cancelRename()">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground shrink-0 border border-border">
|
||||
<Tablet :size="16" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="settings-label truncate leading-none mb-1.5">{{ device.name }}</p>
|
||||
<p class="settings-hint leading-none">Last sync: {{ formatLastSeen(device.lastSeenAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="w-8 h-8 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="startRename(device)"
|
||||
title="Rename device"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-md flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="revoke(device)"
|
||||
title="Revoke access"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Devices -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="settings-group-label mb-0">Registered Devices</p>
|
||||
<button v-if="!showCreateForm" class="settings-btn-primary" @click="showCreateForm = true">
|
||||
<Plus :size="12" />
|
||||
Add device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<div v-if="showCreateForm" class="border border-border rounded-lg p-5 bg-card mb-4 space-y-4 shadow-xs">
|
||||
<div>
|
||||
<label class="settings-label block mb-1.5">Device name</label>
|
||||
<input v-model="newDeviceName" type="text" placeholder="e.g. My Kobo Libra" autofocus class="input-field w-full" />
|
||||
<!-- Sync history -->
|
||||
<div v-show="activeTab === 'activity' && !props.embedded" class="mb-8">
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="settings-group-label mb-0">Recent Kobo Activity</p>
|
||||
<span v-if="latestHistoryEntry" class="h-2 w-2 shrink-0 rounded-full" :class="historyStatusDotClass(latestHistoryEntry)" />
|
||||
</div>
|
||||
<p class="settings-hint mt-1 truncate" :title="historyHeaderDetail">{{ historyHeaderDetail }}</p>
|
||||
</div>
|
||||
<div v-if="createError" class="text-xs text-destructive">{{ createError }}</div>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button class="settings-btn-primary" :disabled="creating || !newDeviceName.trim()" @click="submitCreate()">
|
||||
{{ creating ? 'Creating...' : 'Create device' }}
|
||||
</button>
|
||||
<button class="settings-btn-outline" @click="cancelCreate()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="devices.length === 0 && !showCreateForm" class="border border-border rounded-lg px-5 py-10 bg-card text-center shadow-xs">
|
||||
<div class="w-10 h-10 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<Tablet :size="18" class="text-muted-foreground/70" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-foreground">No devices yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1 max-w-[240px] mx-auto">Add a device to start syncing your books to your Kobo.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="devices.length > 0" class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||
<div v-for="device in devices" :key="device.id" class="px-5 py-4 bg-card transition-colors hover:bg-muted/30">
|
||||
<div v-if="renamingId === device.id" class="flex items-center gap-2">
|
||||
<input v-model="renameValue" type="text" class="flex-1 input-field" @keydown.enter="submitRename(device)" @keydown.esc="cancelRename()" />
|
||||
<button class="settings-btn-primary" :disabled="renaming || !renameValue.trim()" @click="submitRename(device)">Save</button>
|
||||
<button class="settings-btn-outline h-9 w-9 p-0 flex items-center justify-center" @click="cancelRename()">
|
||||
<X :size="14" />
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="inline-flex h-8 rounded-md border border-border bg-muted p-0.5">
|
||||
<button
|
||||
class="rounded-[4px] px-2.5 text-xs font-medium transition-colors inline-flex items-center gap-1.5"
|
||||
:class="historyFilter === 'all' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="showAllHistory"
|
||||
>
|
||||
<span>All</span>
|
||||
<span
|
||||
v-if="syncHistory.length > 0"
|
||||
class="rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="historyFilter === 'all' ? 'text-foreground/80' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ syncHistory.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rounded-[4px] px-2.5 text-xs font-medium transition-colors inline-flex items-center gap-1.5"
|
||||
:class="historyFilter === 'failures' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="showFailedHistory"
|
||||
>
|
||||
<span>Failures</span>
|
||||
<span v-if="recentFailureCount > 0" class="rounded-full bg-destructive/15 px-1.5 py-0.5 text-[10px] font-semibold text-destructive">
|
||||
{{ recentFailureCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground shrink-0 border border-border">
|
||||
<Tablet :size="16" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="settings-label truncate leading-none mb-1.5">{{ device.name }}</p>
|
||||
<p class="settings-hint leading-none">Last sync: {{ formatLastSeen(device.lastSeenAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="refreshingHistory || historyLoading"
|
||||
@click="refreshHistory"
|
||||
>
|
||||
<RefreshCw :size="12" :class="{ 'animate-spin': refreshingHistory || historyLoading }" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-border rounded-lg bg-card p-6 shadow-xs">
|
||||
<div v-if="historyLoading && !hasSyncHistory" class="text-sm text-muted-foreground">Loading Kobo activity...</div>
|
||||
<div v-else-if="!hasSyncHistory" class="text-center py-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<History :size="18" class="text-muted-foreground/80" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-foreground">No Kobo activity yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1 max-w-sm mx-auto">Recent sync activity will appear after a paired Kobo contacts BookOrbit.</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="filteredSyncHistory.length === 0" class="text-sm text-muted-foreground">
|
||||
{{ filteredHistoryEmptyLabel }}
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Vertical track line -->
|
||||
<div class="absolute left-[13px] top-3 bottom-3 w-0.5 bg-border/60"></div>
|
||||
|
||||
<TransitionGroup name="timeline" tag="div" class="space-y-6">
|
||||
<div v-for="item in displayedSyncHistory" :key="item.key" class="relative pl-10">
|
||||
<!-- Icon status indicator -->
|
||||
<div
|
||||
class="absolute left-0 top-0 h-7 w-7 rounded-full border bg-card flex items-center justify-center shadow-xs overflow-hidden"
|
||||
:class="
|
||||
item.primary.status === 'failed'
|
||||
? 'border-destructive/20 text-destructive'
|
||||
: isHistoryNoOp(item.primary)
|
||||
? 'border-border text-muted-foreground'
|
||||
: 'border-primary/20 text-primary'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="h-full w-full rounded-full flex items-center justify-center"
|
||||
:class="item.primary.status === 'failed' ? 'bg-destructive/10' : isHistoryNoOp(item.primary) ? 'bg-muted/10' : 'bg-primary/10'"
|
||||
>
|
||||
<component :is="getHistoryEventIcon(item.primary.event)" :size="12" class="stroke-[2.5]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 pt-0.5">
|
||||
<!-- Header: Title, Outcome Badge, Time -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 text-sm font-semibold text-foreground leading-snug">{{ historyItemTitle(item) }}</span>
|
||||
<span
|
||||
class="shrink-0 rounded-md border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider leading-none"
|
||||
:class="historyOutcomeClass(item.primary)"
|
||||
>
|
||||
{{ historyItemOutcomeText(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground/80" :title="historyItemTimeTitle(item)">
|
||||
{{ formatLastSeen(item.primary.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Messages / Details -->
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-foreground/80 leading-normal">{{ historyItemSummary(item) }}</p>
|
||||
<p v-if="historyItemSecondaryText(item)" class="mt-0.5 text-xs text-muted-foreground leading-normal">
|
||||
{{ historyItemSecondaryText(item) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Callout block for Errors -->
|
||||
<div
|
||||
v-if="item.primary.error"
|
||||
class="mt-1 flex items-start gap-2.5 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive"
|
||||
>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<span class="font-semibold">{{ item.primary.errorClass ?? 'Error' }}</span>
|
||||
<p class="whitespace-pre-wrap break-all leading-normal text-destructive/90">{{ item.primary.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Metadata row -->
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/60">
|
||||
<div class="flex items-center gap-1">
|
||||
<Tablet :size="12" class="text-muted-foreground/40" />
|
||||
<span>{{ historyDeviceName(item.primary) }}</span>
|
||||
</div>
|
||||
<div v-if="historyItemGroupedLabel(item)" class="flex items-center gap-1">
|
||||
<History :size="12" class="text-muted-foreground/40" />
|
||||
<span>{{ historyItemGroupedLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-if="syncHistory.length >= currentLimit && currentLimit < 100" class="mt-6 pl-10">
|
||||
<button
|
||||
class="w-8 h-8 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="startRename(device)"
|
||||
title="Rename device"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 shadow-xs"
|
||||
:disabled="loadingMore || historyLoading"
|
||||
@click="loadMoreHistory"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-md flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="revoke(device)"
|
||||
title="Revoke access"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
<RefreshCw v-if="loadingMore || historyLoading" :size="12" class="animate-spin" />
|
||||
<span>{{ loadingMore || historyLoading ? 'Loading...' : 'Load more activity' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync settings -->
|
||||
<div class="mb-8">
|
||||
<div v-show="activeTab === 'settings' || props.embedded" class="mb-8">
|
||||
<p class="settings-group-label">Sync Preferences</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
@@ -400,3 +993,22 @@ async function saveSettings() {
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline-enter-active,
|
||||
.timeline-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.timeline-enter-from,
|
||||
.timeline-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.timeline-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
.timeline-move {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,25 @@ export interface KoboSyncSettings {
|
||||
syncBookOrbitAnnotationsToKobo: boolean;
|
||||
}
|
||||
|
||||
export type KoboSyncHistoryEvent = "library_sync" | "book_download" | "progress_update" | "annotations_pull" | "annotations_push";
|
||||
|
||||
export type KoboSyncHistoryStatus = "success" | "failed";
|
||||
|
||||
export type KoboSyncHistoryCounts = Record<string, number | boolean | string | null>;
|
||||
|
||||
export interface KoboSyncHistoryEntry {
|
||||
id: number;
|
||||
deviceId: number | null;
|
||||
deviceName: string | null;
|
||||
event: KoboSyncHistoryEvent;
|
||||
status: KoboSyncHistoryStatus;
|
||||
counts: KoboSyncHistoryCounts;
|
||||
durationMs: number;
|
||||
errorClass: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateKoboDeviceRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "kobo_sync_history" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"device_id" integer,
|
||||
"event" varchar(32) NOT NULL,
|
||||
"status" varchar(16) NOT NULL,
|
||||
"counts" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"duration_ms" integer NOT NULL,
|
||||
"error_class" varchar(128),
|
||||
"error" varchar(500),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "kobo_sync_history_event_chk" CHECK ("kobo_sync_history"."event" in ('library_sync', 'book_download', 'progress_update', 'annotations_pull', 'annotations_push')),
|
||||
CONSTRAINT "kobo_sync_history_status_chk" CHECK ("kobo_sync_history"."status" in ('success', 'failed')),
|
||||
CONSTRAINT "kobo_sync_history_duration_nonnegative_chk" CHECK ("kobo_sync_history"."duration_ms" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "kobo_sync_history" ADD CONSTRAINT "kobo_sync_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kobo_sync_history" ADD CONSTRAINT "kobo_sync_history_device_id_kobo_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."kobo_devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "kobo_sync_history_user_created_idx" ON "kobo_sync_history" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "kobo_sync_history_device_created_idx" ON "kobo_sync_history" USING btree ("device_id","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
||||
"when": 1783009954135,
|
||||
"tag": "0035_add_koreader_unmatched_book_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1783034396046,
|
||||
"tag": "0036_kobo-sync-history",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -137,6 +137,34 @@ export const koboReadingStates = pgTable(
|
||||
(t) => [unique().on(t.userId, t.bookId)],
|
||||
);
|
||||
|
||||
export const koboSyncHistory = pgTable(
|
||||
'kobo_sync_history',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
deviceId: integer('device_id').references(() => koboDevices.id, { onDelete: 'set null' }),
|
||||
event: varchar('event', { length: 32 }).notNull(),
|
||||
status: varchar('status', { length: 16 }).notNull(),
|
||||
counts: jsonb('counts').notNull().default({}),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
errorClass: varchar('error_class', { length: 128 }),
|
||||
error: varchar('error', { length: 500 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('kobo_sync_history_user_created_idx').on(t.userId, t.createdAt),
|
||||
index('kobo_sync_history_device_created_idx').on(t.deviceId, t.createdAt),
|
||||
check(
|
||||
'kobo_sync_history_event_chk',
|
||||
sql`${t.event} in ('library_sync', 'book_download', 'progress_update', 'annotations_pull', 'annotations_push')`,
|
||||
),
|
||||
check('kobo_sync_history_status_chk', sql`${t.status} in ('success', 'failed')`),
|
||||
check('kobo_sync_history_duration_nonnegative_chk', sql`${t.durationMs} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export type KoboDevice = typeof koboDevices.$inferSelect;
|
||||
export type NewKoboDevice = typeof koboDevices.$inferInsert;
|
||||
|
||||
@@ -145,3 +173,5 @@ export type KoboLibrarySnapshot = typeof koboLibrarySnapshots.$inferSelect;
|
||||
export type KoboSnapshotBook = typeof koboSnapshotBooks.$inferSelect;
|
||||
export type KoboBookEntitlement = typeof koboBookEntitlements.$inferSelect;
|
||||
export type KoboReadingState = typeof koboReadingStates.$inferSelect;
|
||||
export type KoboSyncHistory = typeof koboSyncHistory.$inferSelect;
|
||||
export type NewKoboSyncHistory = typeof koboSyncHistory.$inferInsert;
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/kobo/services/kobo-download.service.ts',
|
||||
'src/modules/kobo/services/kobo-reading-state.service.ts',
|
||||
'src/modules/kobo/services/kobo-settings.service.ts',
|
||||
'src/modules/kobo/services/kobo-sync-history.service.ts',
|
||||
'src/modules/kobo/services/kobo-sync.service.ts',
|
||||
'src/modules/koreader/koreader-chapter-extractor.service.ts',
|
||||
'src/modules/metadata/metadata.service.ts',
|
||||
|
||||
@@ -26,6 +26,11 @@ describe('KoboDeviceController', () => {
|
||||
resolveBookIdByEntitlementId: vi.fn(),
|
||||
resolveBookIdByCoverImageId: vi.fn(),
|
||||
};
|
||||
const historyService = {
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn(),
|
||||
countsForBook: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new KoboDeviceController(
|
||||
thumbnailService as never,
|
||||
@@ -33,10 +38,16 @@ describe('KoboDeviceController', () => {
|
||||
proxyService as never,
|
||||
analyticsService as never,
|
||||
bookIdentityService as never,
|
||||
historyService as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
historyService.countsForBook.mockImplementation((_userId: number, bookId: number, counts: Record<string, unknown>) => ({
|
||||
...counts,
|
||||
bookId,
|
||||
bookTitle: 'Dune',
|
||||
}));
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) => (/^\d+$/.test(id) ? Number(id) : null));
|
||||
bookIdentityService.resolveBookIdByCoverImageId.mockImplementation((_userId: number, id: string) => {
|
||||
const match = /^(\d+)(?:-\d+)?$/.exec(id);
|
||||
@@ -102,9 +113,31 @@ describe('KoboDeviceController', () => {
|
||||
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/9/download' };
|
||||
const reply = makeReply();
|
||||
|
||||
await controller.download('9', { id: 11 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
|
||||
await controller.download('9', { id: 11 } as never, { deviceId: 4, deviceToken: 'dev-token' } as never, req as never, reply as never);
|
||||
|
||||
expect(downloadService.streamBook).toHaveBeenCalledWith(11, 9, reply);
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 11,
|
||||
deviceId: 4,
|
||||
event: 'book_download',
|
||||
counts: { downloads: 1, bookId: 9, bookTitle: 'Dune' },
|
||||
}),
|
||||
);
|
||||
expect(historyService.countsForBook).toHaveBeenCalledWith(11, 9, { downloads: 1 });
|
||||
});
|
||||
|
||||
it('records failed downloads before rethrowing', async () => {
|
||||
const error = new Error('file missing');
|
||||
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/9/download' };
|
||||
const reply = makeReply();
|
||||
downloadService.streamBook.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
controller.download('9', { id: 11 } as never, { deviceId: 4, deviceToken: 'dev-token' } as never, req as never, reply as never),
|
||||
).rejects.toThrow('file missing');
|
||||
|
||||
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 11, deviceId: 4, event: 'book_download' }), error);
|
||||
});
|
||||
|
||||
it('streams downloads and thumbnails for resolved Kobo UUID ids', async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { KoboDownloadService } from './services/kobo-download.service';
|
||||
import { KoboProxyService } from './services/kobo-proxy.service';
|
||||
import type { KoboAnalyticsBody } from './kobo-analytics.types';
|
||||
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
|
||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||
|
||||
@Controller('kobo/:deviceToken')
|
||||
@Public()
|
||||
@@ -27,6 +28,7 @@ export class KoboDeviceController {
|
||||
private readonly proxyService: KoboProxyService,
|
||||
private readonly analyticsService: KoboAnalyticsService,
|
||||
private readonly bookIdentityService: KoboBookIdentityService,
|
||||
private readonly historyService: KoboSyncHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('v1/books/:bookId/thumbnail/:width/:height/:quality/:isGreyscale/image.jpg')
|
||||
@@ -75,7 +77,28 @@ export class KoboDeviceController {
|
||||
) {
|
||||
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
|
||||
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
await this.downloadService.streamBook(user.id, id, reply);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.downloadService.streamBook(user.id, id, reply);
|
||||
await this.historyService.recordSuccess({
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'book_download',
|
||||
durationMs: Date.now() - startedAt,
|
||||
counts: await this.historyService.countsForBook(user.id, id, { downloads: 1 }),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
await this.historyService.recordFailure(
|
||||
{
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'book_download',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Get('v1/affiliate')
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
import { KoboReadingServicesController } from './kobo-reading-services.controller';
|
||||
|
||||
function makeReply() {
|
||||
return {
|
||||
raw: { once: vi.fn() },
|
||||
status: vi.fn().mockReturnThis(),
|
||||
header: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KoboReadingServicesController', () => {
|
||||
const exchangeService = {
|
||||
getContentAnnotations: vi.fn(),
|
||||
markServedSeen: vi.fn(),
|
||||
patchContentAnnotations: vi.fn(),
|
||||
getChangedContentIds: vi.fn(),
|
||||
};
|
||||
const historyService = {
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn(),
|
||||
countsForBook: vi.fn(),
|
||||
};
|
||||
const controller = new KoboReadingServicesController(exchangeService as never, historyService as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
historyService.countsForBook.mockImplementation((_userId: number, bookId: number, counts: Record<string, unknown>) => ({
|
||||
...counts,
|
||||
bookId,
|
||||
bookTitle: 'Dune',
|
||||
}));
|
||||
});
|
||||
|
||||
it('records successful annotation pulls and sends the response', async () => {
|
||||
const servedAck = { entries: [], tombstoneStateIds: [12] };
|
||||
const response = { annotations: [{ id: 'a1' }], nextPageOffsetToken: null };
|
||||
exchangeService.getContentAnnotations.mockResolvedValue({
|
||||
bookId: 42,
|
||||
etag: '"abc"',
|
||||
notModified: false,
|
||||
response,
|
||||
servedAck,
|
||||
servedCount: 1,
|
||||
tombstoneCount: 1,
|
||||
});
|
||||
const reply = makeReply();
|
||||
|
||||
await controller.getAnnotations('content-1', undefined, { id: 8 } as never, { deviceId: 5 } as never, reply as never);
|
||||
|
||||
expect(exchangeService.getContentAnnotations).toHaveBeenCalledWith(8, 'content-1', 5, undefined);
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 8,
|
||||
deviceId: 5,
|
||||
event: 'annotations_pull',
|
||||
counts: { served: 1, tombstones: 1, notModified: false, bookId: 42, bookTitle: 'Dune' },
|
||||
}),
|
||||
);
|
||||
expect(historyService.countsForBook).toHaveBeenCalledWith(8, 42, { served: 1, tombstones: 1, notModified: false });
|
||||
expect(reply.header).toHaveBeenCalledWith('ETag', '"abc"');
|
||||
expect(reply.raw.once).toHaveBeenCalledWith('finish', expect.any(Function));
|
||||
expect(reply.send).toHaveBeenCalledWith(response);
|
||||
});
|
||||
|
||||
it('records not-modified annotation pulls', async () => {
|
||||
exchangeService.getContentAnnotations.mockResolvedValue({
|
||||
bookId: 42,
|
||||
etag: '"abc"',
|
||||
notModified: true,
|
||||
servedAck: { entries: [], tombstoneStateIds: [] },
|
||||
servedCount: 0,
|
||||
tombstoneCount: 0,
|
||||
});
|
||||
const reply = makeReply();
|
||||
|
||||
await controller.getAnnotations('content-1', 'W/"0"', { id: 8 } as never, { deviceId: 5 } as never, reply as never);
|
||||
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'annotations_pull',
|
||||
counts: { served: 0, tombstones: 0, notModified: true, bookId: 42, bookTitle: 'Dune' },
|
||||
}),
|
||||
);
|
||||
expect(reply.status).toHaveBeenCalledWith(HttpStatus.NOT_MODIFIED);
|
||||
expect(reply.send).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('records failed annotation pulls before rethrowing', async () => {
|
||||
const error = new Error('annotation lookup failed');
|
||||
exchangeService.getContentAnnotations.mockRejectedValueOnce(error);
|
||||
const reply = makeReply();
|
||||
|
||||
await expect(controller.getAnnotations('content-1', undefined, { id: 8 } as never, { deviceId: 5 } as never, reply as never)).rejects.toThrow(
|
||||
'annotation lookup failed',
|
||||
);
|
||||
|
||||
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 8, deviceId: 5, event: 'annotations_pull' }), error);
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records successful annotation pushes', async () => {
|
||||
exchangeService.patchContentAnnotations.mockResolvedValue({
|
||||
bookId: 42,
|
||||
created: 2,
|
||||
updated: 1,
|
||||
unchanged: 3,
|
||||
deleted: 1,
|
||||
kepubReady: true,
|
||||
});
|
||||
|
||||
await controller.patchAnnotations('content-1', { annotations: [] }, { id: 8 } as never, { deviceId: 5 } as never);
|
||||
|
||||
expect(exchangeService.patchContentAnnotations).toHaveBeenCalledWith(8, 'content-1', { annotations: [] }, 5);
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 8,
|
||||
deviceId: 5,
|
||||
event: 'annotations_push',
|
||||
counts: { created: 2, updated: 1, unchanged: 3, deleted: 1, kepubReady: true, bookId: 42, bookTitle: 'Dune' },
|
||||
}),
|
||||
);
|
||||
expect(historyService.countsForBook).toHaveBeenCalledWith(8, 42, { created: 2, updated: 1, unchanged: 3, deleted: 1, kepubReady: true });
|
||||
});
|
||||
|
||||
it('records failed annotation pushes before rethrowing', async () => {
|
||||
const error = new Error('annotation write failed');
|
||||
exchangeService.patchContentAnnotations.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(controller.patchAnnotations('content-1', {}, { id: 8 } as never, { deviceId: 5 } as never)).rejects.toThrow(
|
||||
'annotation write failed',
|
||||
);
|
||||
|
||||
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 8, deviceId: 5, event: 'annotations_push' }), error);
|
||||
});
|
||||
|
||||
it('delegates changed content and storage metadata without history rows', async () => {
|
||||
exchangeService.getChangedContentIds.mockResolvedValue(['book-1']);
|
||||
|
||||
await expect(controller.checkForChanges({ id: 8 } as never, { deviceId: 5 } as never)).resolves.toEqual(['book-1']);
|
||||
expect(controller.getUserStorageMetadata()).toEqual({ continuationToken: null, metadata: [] });
|
||||
expect(historyService.recordSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,16 @@ import { KoboDevice } from './decorators/kobo-device.decorator';
|
||||
import type { KoboDeviceContext } from './guards/kobo-token.guard';
|
||||
import { KoboTokenGuard } from './guards/kobo-token.guard';
|
||||
import { KoboAnnotationExchangeService } from './services/kobo-annotation-exchange.service';
|
||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||
|
||||
@Controller(['kobo/:deviceToken', ''])
|
||||
@Public()
|
||||
@UseGuards(KoboTokenGuard)
|
||||
export class KoboReadingServicesController {
|
||||
constructor(private readonly exchangeService: KoboAnnotationExchangeService) {}
|
||||
constructor(
|
||||
private readonly exchangeService: KoboAnnotationExchangeService,
|
||||
private readonly historyService: KoboSyncHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('api/v3/content/:contentId/annotations')
|
||||
async getAnnotations(
|
||||
@@ -23,7 +27,33 @@ export class KoboReadingServicesController {
|
||||
@KoboDevice() device: KoboDeviceContext,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const result = await this.exchangeService.getContentAnnotations(user.id, contentId, device.deviceId, ifNoneMatch);
|
||||
const startedAt = Date.now();
|
||||
let result: Awaited<ReturnType<KoboAnnotationExchangeService['getContentAnnotations']>>;
|
||||
try {
|
||||
result = await this.exchangeService.getContentAnnotations(user.id, contentId, device.deviceId, ifNoneMatch);
|
||||
await this.historyService.recordSuccess({
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'annotations_pull',
|
||||
durationMs: Date.now() - startedAt,
|
||||
counts: await this.historyService.countsForBook(user.id, result.bookId, {
|
||||
served: result.servedCount,
|
||||
tombstones: result.tombstoneCount,
|
||||
notModified: result.notModified,
|
||||
}),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
await this.historyService.recordFailure(
|
||||
{
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'annotations_pull',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
reply.header('ETag', result.etag);
|
||||
|
||||
// This Kobo firmware never echoes our ETag back (it always sends If-None-Match: W/"0" and drives
|
||||
@@ -50,7 +80,34 @@ export class KoboReadingServicesController {
|
||||
@CurrentUser() user: RequestUser,
|
||||
@KoboDevice() device: KoboDeviceContext,
|
||||
) {
|
||||
await this.exchangeService.patchContentAnnotations(user.id, contentId, body, device.deviceId);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await this.exchangeService.patchContentAnnotations(user.id, contentId, body, device.deviceId);
|
||||
await this.historyService.recordSuccess({
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'annotations_push',
|
||||
durationMs: Date.now() - startedAt,
|
||||
counts: await this.historyService.countsForBook(user.id, result.bookId, {
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
unchanged: result.unchanged,
|
||||
deleted: result.deleted,
|
||||
kepubReady: result.kepubReady,
|
||||
}),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
await this.historyService.recordFailure(
|
||||
{
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'annotations_push',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('api/v3/content/checkforchanges')
|
||||
|
||||
@@ -29,6 +29,11 @@ describe('KoboSyncController', () => {
|
||||
const bookIdentityService = {
|
||||
resolveBookIdByEntitlementId: vi.fn(),
|
||||
};
|
||||
const historyService = {
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn(),
|
||||
countsForBook: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new KoboSyncController(
|
||||
settingsService as never,
|
||||
@@ -36,11 +41,17 @@ describe('KoboSyncController', () => {
|
||||
readingStateService as never,
|
||||
proxyService as never,
|
||||
bookIdentityService as never,
|
||||
historyService as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) => (/^\d+$/.test(id) ? Number(id) : null));
|
||||
historyService.countsForBook.mockImplementation((_userId: number, bookId: number, counts: Record<string, unknown>) => ({
|
||||
...counts,
|
||||
bookId,
|
||||
bookTitle: 'Dune',
|
||||
}));
|
||||
});
|
||||
|
||||
it('initialization builds resource URLs from forwarded headers', () => {
|
||||
@@ -183,15 +194,42 @@ describe('KoboSyncController', () => {
|
||||
};
|
||||
const reply = makeReply();
|
||||
|
||||
await controller.librarySync({ deviceToken: 'token-9' } as never, { id: 21 } as never, 'old-sync', req as never, reply as never);
|
||||
await controller.librarySync({ deviceId: 9, deviceToken: 'token-9' } as never, { id: 21 } as never, 'old-sync', req as never, reply as never);
|
||||
|
||||
expect(settingsService.getSettings).not.toHaveBeenCalled();
|
||||
expect(syncService.getDelta).toHaveBeenCalledWith(21, 'token-9', 'http://kobo.local:3000');
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 21,
|
||||
deviceId: 9,
|
||||
event: 'library_sync',
|
||||
counts: { entitlements: 1, hasMore: true },
|
||||
}),
|
||||
);
|
||||
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue');
|
||||
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'SYNC-2');
|
||||
expect(reply.send).toHaveBeenCalledWith([{ NewEntitlement: { BookEntitlement: { Id: '12' } } }]);
|
||||
});
|
||||
|
||||
it('records failed library sync operations before rethrowing', async () => {
|
||||
const error = new Error('snapshot failed');
|
||||
syncService.getDelta.mockRejectedValue(error);
|
||||
const req = {
|
||||
headers: { host: 'kobo.local' },
|
||||
protocol: 'http',
|
||||
hostname: 'kobo.local',
|
||||
socket: { localPort: 3000 },
|
||||
};
|
||||
const reply = makeReply();
|
||||
|
||||
await expect(
|
||||
controller.librarySync({ deviceId: 10, deviceToken: 'token-10' } as never, { id: 22 } as never, undefined, req as never, reply as never),
|
||||
).rejects.toThrow('snapshot failed');
|
||||
|
||||
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 22, deviceId: 10, event: 'library_sync' }), error);
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acknowledges delete-items requests for synced BookOrbit collection tags', async () => {
|
||||
const req = { method: 'POST', url: '/api/v1/kobo/token/v1/library/tags/col-1/items/delete' };
|
||||
const reply = makeReply();
|
||||
@@ -299,7 +337,7 @@ describe('KoboSyncController', () => {
|
||||
CurrentBookmark: { ProgressPercent: 10 },
|
||||
},
|
||||
{ id: 8 } as never,
|
||||
{ deviceToken: 'dev77' } as never,
|
||||
{ deviceId: 77, deviceToken: 'dev77' } as never,
|
||||
{ method: 'PUT', url: '/api/v1/kobo/dev77/v1/library/77/state' } as never,
|
||||
reply as never,
|
||||
);
|
||||
@@ -312,6 +350,43 @@ describe('KoboSyncController', () => {
|
||||
99,
|
||||
true,
|
||||
);
|
||||
expect(historyService.recordSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 8,
|
||||
deviceId: 77,
|
||||
event: 'progress_update',
|
||||
counts: { progressUpdates: 1, twoWayProgressSync: true, bookId: 77, bookTitle: 'Dune' },
|
||||
}),
|
||||
);
|
||||
expect(historyService.countsForBook).toHaveBeenCalledWith(8, 77, { progressUpdates: 1, twoWayProgressSync: true });
|
||||
expect(reply.send).toHaveBeenCalledWith({ RequestResult: 'Success' });
|
||||
});
|
||||
|
||||
it('records failed reading-state updates before rethrowing', async () => {
|
||||
const error = new Error('state failed');
|
||||
settingsService.getSettings.mockResolvedValue({
|
||||
readingThreshold: 1,
|
||||
finishedThreshold: 99,
|
||||
convertToKepub: true,
|
||||
forceEnableHyphenation: false,
|
||||
kepubConversionLimitMb: 100,
|
||||
twoWayProgressSync: true,
|
||||
});
|
||||
readingStateService.upsertState.mockRejectedValue(error);
|
||||
const reply = makeReply();
|
||||
|
||||
await expect(
|
||||
controller.updateReadingState(
|
||||
'77',
|
||||
{ CurrentBookmark: { ProgressPercent: 56 } },
|
||||
{ id: 8 } as never,
|
||||
{ deviceId: 77, deviceToken: 'dev77' } as never,
|
||||
{ method: 'PUT', url: '/api/v1/kobo/dev77/v1/library/77/state' } as never,
|
||||
reply as never,
|
||||
),
|
||||
).rejects.toThrow('state failed');
|
||||
|
||||
expect(historyService.recordFailure).toHaveBeenCalledWith(expect.objectContaining({ userId: 8, deviceId: 77, event: 'progress_update' }), error);
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
||||
import { KoboProxyService } from './services/kobo-proxy.service';
|
||||
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
|
||||
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
|
||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||
|
||||
function readHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
@@ -85,6 +86,7 @@ export class KoboSyncController {
|
||||
private readonly readingStateService: KoboReadingStateService,
|
||||
private readonly proxyService: KoboProxyService,
|
||||
private readonly bookIdentityService: KoboBookIdentityService,
|
||||
private readonly historyService: KoboSyncHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('v1/initialization')
|
||||
@@ -113,9 +115,32 @@ export class KoboSyncController {
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`librarySync: userId=${user.id} syncToken=${incomingToken ?? 'none'}`);
|
||||
const baseUrl = buildBaseUrl(req);
|
||||
const { entitlements, hasMore, syncToken } = await this.syncService.getDelta(user.id, device.deviceToken, baseUrl);
|
||||
let result: { entitlements: unknown[]; hasMore: boolean; syncToken: string };
|
||||
try {
|
||||
result = await this.syncService.getDelta(user.id, device.deviceToken, baseUrl);
|
||||
} catch (error: unknown) {
|
||||
await this.historyService.recordFailure(
|
||||
{
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'library_sync',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const { entitlements, hasMore, syncToken } = result;
|
||||
await this.historyService.recordSuccess({
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'library_sync',
|
||||
durationMs: Date.now() - startedAt,
|
||||
counts: { entitlements: entitlements.length, hasMore },
|
||||
});
|
||||
reply.header('x-kobo-sync', hasMore ? 'continue' : '');
|
||||
reply.header('x-kobo-synctoken', syncToken);
|
||||
reply.send(entitlements);
|
||||
@@ -196,17 +221,38 @@ export class KoboSyncController {
|
||||
) {
|
||||
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
|
||||
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
const settings = await this.settingsService.getSettings(user.id);
|
||||
const states = body.ReadingStates as Record<string, unknown>[] | undefined;
|
||||
const statePayload = states?.[0] ?? body;
|
||||
const result = await this.readingStateService.upsertState(
|
||||
user.id,
|
||||
id,
|
||||
statePayload,
|
||||
settings.readingThreshold,
|
||||
settings.finishedThreshold,
|
||||
settings.twoWayProgressSync,
|
||||
);
|
||||
reply.send(result);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const settings = await this.settingsService.getSettings(user.id);
|
||||
const states = body.ReadingStates as Record<string, unknown>[] | undefined;
|
||||
const statePayload = states?.[0] ?? body;
|
||||
const result = await this.readingStateService.upsertState(
|
||||
user.id,
|
||||
id,
|
||||
statePayload,
|
||||
settings.readingThreshold,
|
||||
settings.finishedThreshold,
|
||||
settings.twoWayProgressSync,
|
||||
);
|
||||
await this.historyService.recordSuccess({
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'progress_update',
|
||||
durationMs: Date.now() - startedAt,
|
||||
counts: await this.historyService.countsForBook(user.id, id, { progressUpdates: 1, twoWayProgressSync: settings.twoWayProgressSync }),
|
||||
});
|
||||
reply.send(result);
|
||||
} catch (error: unknown) {
|
||||
await this.historyService.recordFailure(
|
||||
{
|
||||
userId: user.id,
|
||||
deviceId: device.deviceId,
|
||||
event: 'progress_update',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ describe('KoboUserController', () => {
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
};
|
||||
const controller = new KoboUserController(deviceService as never, settingsService as never);
|
||||
const historyService = {
|
||||
listForUser: vi.fn(),
|
||||
};
|
||||
const controller = new KoboUserController(deviceService as never, settingsService as never, historyService as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -70,6 +73,14 @@ describe('KoboUserController', () => {
|
||||
expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { convertToKepub: false, twoWayProgressSync: true });
|
||||
});
|
||||
|
||||
it('delegates history reads with current user id and limit', async () => {
|
||||
historyService.listForUser.mockResolvedValue([{ id: 1, event: 'library_sync' }]);
|
||||
|
||||
await expect(controller.listHistory({ id: 9 } as never, 15)).resolves.toEqual([{ id: 1, event: 'library_sync' }]);
|
||||
|
||||
expect(historyService.listForUser).toHaveBeenCalledWith(9, 15);
|
||||
});
|
||||
|
||||
it('registers auditable metadata for create/rename/remove actions', () => {
|
||||
const createAudit = Reflect.getMetadata(AUDITABLE_KEY, KoboUserController.prototype.createDevice) as {
|
||||
action: AuditAction;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Permission, AuditAction, AuditResource } from '@bookorbit/types';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
@@ -10,6 +10,7 @@ import { RenameDeviceDto } from './dto/rename-device.dto';
|
||||
import { UpdateSettingsDto } from './dto/update-settings.dto';
|
||||
import { KoboDeviceService } from './services/kobo-device.service';
|
||||
import { KoboSettingsService } from './services/kobo-settings.service';
|
||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||
|
||||
@Controller('kobo')
|
||||
@RequirePermission(Permission.KoboSync)
|
||||
@@ -17,6 +18,7 @@ export class KoboUserController {
|
||||
constructor(
|
||||
private readonly deviceService: KoboDeviceService,
|
||||
private readonly settingsService: KoboSettingsService,
|
||||
private readonly historyService: KoboSyncHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('devices')
|
||||
@@ -67,4 +69,9 @@ export class KoboUserController {
|
||||
updateSettings(@Body() dto: UpdateSettingsDto, @CurrentUser() user: RequestUser) {
|
||||
return this.settingsService.updateSettings(user.id, dto);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
listHistory(@CurrentUser() user: RequestUser, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number) {
|
||||
return this.historyService.listForUser(user.id, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { KoboProgressBridgeService } from './services/kobo-progress-bridge.servi
|
||||
import { KoboProxyService } from './services/kobo-proxy.service';
|
||||
import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
||||
import { KoboSettingsService } from './services/kobo-settings.service';
|
||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||
import { KoboSyncService } from './services/kobo-sync.service';
|
||||
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
|
||||
import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver.service';
|
||||
@@ -56,6 +57,7 @@ describe('KoboModule', () => {
|
||||
KoboAnnotationMaterializerService,
|
||||
KoboAnnotationExchangeService,
|
||||
KoboReadingStateService,
|
||||
KoboSyncHistoryService,
|
||||
KoboThumbnailService,
|
||||
KoboDownloadService,
|
||||
KoboProxyService,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { KoboProxyService } from './services/kobo-proxy.service';
|
||||
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
|
||||
import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
||||
import { KoboSettingsService } from './services/kobo-settings.service';
|
||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||
import { KoboSyncService } from './services/kobo-sync.service';
|
||||
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
|
||||
import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver.service';
|
||||
@@ -58,6 +59,7 @@ import { KoboAnalyticsService } from './services/kobo-analytics.service';
|
||||
KoboAnnotationMaterializerService,
|
||||
KoboAnnotationExchangeService,
|
||||
KoboReadingStateService,
|
||||
KoboSyncHistoryService,
|
||||
KoboThumbnailService,
|
||||
KoboDownloadService,
|
||||
KoboProxyService,
|
||||
|
||||
@@ -29,12 +29,24 @@ export interface ServedAckPlan {
|
||||
}
|
||||
|
||||
export interface KoboContentAnnotationsResult {
|
||||
bookId: number;
|
||||
etag: string;
|
||||
notModified: boolean;
|
||||
servedCount: number;
|
||||
tombstoneCount: number;
|
||||
response?: KoboReadingServiceAnnotationsResponse;
|
||||
servedAck: ServedAckPlan;
|
||||
}
|
||||
|
||||
export interface KoboPatchAnnotationsResult {
|
||||
bookId: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
deleted: number;
|
||||
kepubReady: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KoboAnnotationExchangeService {
|
||||
private readonly logger = new Logger(KoboAnnotationExchangeService.name);
|
||||
@@ -124,11 +136,16 @@ export class KoboAnnotationExchangeService {
|
||||
`[${GET_EVENT}] [end] userId=${userId} bookId=${bookId} deviceId=${deviceId} durationMs=${Date.now() - startedAtMs} served=${served.length} tombstones=${servedAck.tombstoneStateIds.length} skippedNoLocation=${skippedNoLocation} notModified=${notModified} - kobo annotations served`,
|
||||
);
|
||||
|
||||
if (notModified) return { etag, notModified: true, servedAck };
|
||||
if (notModified) {
|
||||
return { bookId, etag, notModified: true, servedAck, servedCount: served.length, tombstoneCount: servedAck.tombstoneStateIds.length };
|
||||
}
|
||||
return {
|
||||
bookId,
|
||||
etag,
|
||||
notModified: false,
|
||||
servedAck,
|
||||
servedCount: served.length,
|
||||
tombstoneCount: servedAck.tombstoneStateIds.length,
|
||||
response: {
|
||||
annotations: served.map((row) => toKoboReadingServiceAnnotation(row)),
|
||||
nextPageOffsetToken: null,
|
||||
@@ -147,13 +164,13 @@ export class KoboAnnotationExchangeService {
|
||||
});
|
||||
}
|
||||
|
||||
async patchContentAnnotations(userId: number, contentId: string, body: unknown, deviceId: number): Promise<void> {
|
||||
async patchContentAnnotations(userId: number, contentId: string, body: unknown, deviceId: number): Promise<KoboPatchAnnotationsResult> {
|
||||
const startedAtMs = Date.now();
|
||||
const bookId = await this.resolveBookIdByContentId(userId, contentId);
|
||||
if (bookId === null) throw new NotFoundException(`Kobo content ${contentId} not found`);
|
||||
|
||||
const operations = extractKoboAnnotationOperations(body);
|
||||
if (operations.length === 0) return;
|
||||
if (operations.length === 0) return { bookId, created: 0, updated: 0, unchanged: 0, deleted: 0, kepubReady: false };
|
||||
|
||||
const upserts = operations.filter((operation): operation is IncomingUpsert => operation.kind === 'upsert');
|
||||
const deletes = operations.filter((operation) => operation.kind === 'delete');
|
||||
@@ -193,6 +210,7 @@ export class KoboAnnotationExchangeService {
|
||||
this.logger.log(
|
||||
`[${PATCH_EVENT}] [end] userId=${userId} bookId=${bookId} deviceId=${deviceId} durationMs=${Date.now() - startedAtMs} created=${result.created} updated=${result.updated} unchanged=${result.unchanged} deleted=${deletedCount} kepubReady=${kepub.ok} - kobo annotation operations applied`,
|
||||
);
|
||||
return { bookId, created: result.created, updated: result.updated, unchanged: result.unchanged, deleted: deletedCount, kepubReady: kepub.ok };
|
||||
}
|
||||
|
||||
async getChangedContentIds(userId: number, deviceId: number): Promise<string[]> {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { KoboSyncHistoryService } from './kobo-sync-history.service';
|
||||
|
||||
function makeSelectChain(rows: unknown[]) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeBookAccessService() {
|
||||
return {
|
||||
assertBookAccessible: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KoboSyncHistoryService', () => {
|
||||
it('lists recent history for a user and normalizes response fields', async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 12,
|
||||
deviceId: 3,
|
||||
deviceName: 'Kobo Libra',
|
||||
event: 'library_sync',
|
||||
status: 'success',
|
||||
counts: { entitlements: 4, hasMore: false },
|
||||
durationMs: 42,
|
||||
errorClass: null,
|
||||
error: null,
|
||||
createdAt: new Date('2026-06-01T10:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
deviceId: null,
|
||||
deviceName: null,
|
||||
event: 'book_download',
|
||||
status: 'failed',
|
||||
counts: null,
|
||||
durationMs: 5,
|
||||
errorClass: 'NotFoundException',
|
||||
error: 'File not found',
|
||||
createdAt: new Date('2026-06-01T09:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
const selectChain = makeSelectChain(rows);
|
||||
const db = { select: vi.fn().mockReturnValue(selectChain) };
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await expect(service.listForUser(7, 500)).resolves.toEqual([
|
||||
{
|
||||
id: 12,
|
||||
deviceId: 3,
|
||||
deviceName: 'Kobo Libra',
|
||||
event: 'library_sync',
|
||||
status: 'success',
|
||||
counts: { entitlements: 4, hasMore: false },
|
||||
durationMs: 42,
|
||||
errorClass: null,
|
||||
error: null,
|
||||
createdAt: '2026-06-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
deviceId: null,
|
||||
deviceName: null,
|
||||
event: 'book_download',
|
||||
status: 'failed',
|
||||
counts: {},
|
||||
durationMs: 5,
|
||||
errorClass: 'NotFoundException',
|
||||
error: 'File not found',
|
||||
createdAt: '2026-06-01T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(100);
|
||||
});
|
||||
|
||||
it('applies default limit of 20 when no limit argument is passed', async () => {
|
||||
const selectChain = makeSelectChain([]);
|
||||
const db = { select: vi.fn().mockReturnValue(selectChain) };
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await service.listForUser(7);
|
||||
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('clamps limit to minimum of 1 when zero is passed', async () => {
|
||||
const selectChain = makeSelectChain([]);
|
||||
const db = { select: vi.fn().mockReturnValue(selectChain) };
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await service.listForUser(7, 0);
|
||||
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('records successful events and prunes old user history', async () => {
|
||||
const values = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
insert: vi.fn().mockReturnValue({ values }),
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await service.recordSuccess({
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'library_sync',
|
||||
durationMs: 12.9,
|
||||
counts: { entitlements: 4, hasMore: false },
|
||||
});
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'library_sync',
|
||||
status: 'success',
|
||||
counts: { entitlements: 4, hasMore: false },
|
||||
durationMs: 12,
|
||||
errorClass: null,
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('records failed events with sanitized short error fields', async () => {
|
||||
const values = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
insert: vi.fn().mockReturnValue({ values }),
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await service.recordFailure(
|
||||
{
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'progress_update',
|
||||
durationMs: -10,
|
||||
},
|
||||
new Error('bad "thing"\\path\nnext line'),
|
||||
);
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'progress_update',
|
||||
status: 'failed',
|
||||
counts: {},
|
||||
durationMs: 0,
|
||||
errorClass: 'Error',
|
||||
error: 'bad \\"thing\\"\\\\path next line',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records failed events from non-Error values', async () => {
|
||||
const values = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
insert: vi.fn().mockReturnValue({ values }),
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await service.recordFailure(
|
||||
{
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'annotations_push',
|
||||
durationMs: 4,
|
||||
},
|
||||
'string failure',
|
||||
);
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
errorClass: 'Error',
|
||||
error: 'string failure',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when recording history fails', async () => {
|
||||
const db = {
|
||||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockRejectedValue(new Error('db unavailable')) }),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await expect(
|
||||
service.recordSuccess({
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'annotations_pull',
|
||||
durationMs: 1,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when recording history fails with a non-Error value', async () => {
|
||||
const db = {
|
||||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockRejectedValue('db unavailable') }),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await expect(
|
||||
service.recordSuccess({
|
||||
userId: 7,
|
||||
deviceId: 3,
|
||||
event: 'annotations_pull',
|
||||
durationMs: 1,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds book context to history counts', async () => {
|
||||
const selectChain = makeSelectChain([{ title: 'Dune' }]);
|
||||
const db = { select: vi.fn().mockReturnValue(selectChain) };
|
||||
const bookAccessService = makeBookAccessService();
|
||||
const service = new KoboSyncHistoryService(db as never, bookAccessService as never);
|
||||
|
||||
await expect(service.countsForBook(7, 44, { progressUpdates: 1 })).resolves.toEqual({
|
||||
progressUpdates: 1,
|
||||
bookId: 44,
|
||||
bookTitle: 'Dune',
|
||||
});
|
||||
|
||||
expect(bookAccessService.assertBookAccessible).toHaveBeenCalledWith(7, 44);
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('keeps a null book title when the book card is not found', async () => {
|
||||
const selectChain = makeSelectChain([]);
|
||||
const db = { select: vi.fn().mockReturnValue(selectChain) };
|
||||
const service = new KoboSyncHistoryService(db as never, makeBookAccessService() as never);
|
||||
|
||||
await expect(service.countsForBook(7, 45)).resolves.toEqual({
|
||||
bookId: 45,
|
||||
bookTitle: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps counts when book context lookup is not accessible', async () => {
|
||||
const db = { select: vi.fn() };
|
||||
const bookAccessService = {
|
||||
assertBookAccessible: vi.fn().mockRejectedValue(new Error('not allowed')),
|
||||
};
|
||||
const service = new KoboSyncHistoryService(db as never, bookAccessService as never);
|
||||
|
||||
await expect(service.countsForBook(7, 45, { downloads: 1 })).resolves.toEqual({
|
||||
downloads: 1,
|
||||
bookId: 45,
|
||||
bookTitle: null,
|
||||
});
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { desc, eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type { KoboSyncHistoryCounts, KoboSyncHistoryEntry, KoboSyncHistoryEvent } from '@bookorbit/types';
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
|
||||
import { KoboBookAccessService } from './kobo-book-access.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
const HISTORY_LIMIT = 100;
|
||||
const DEFAULT_LIST_LIMIT = 20;
|
||||
|
||||
interface RecordBaseInput {
|
||||
userId: number;
|
||||
deviceId: number;
|
||||
event: KoboSyncHistoryEvent;
|
||||
durationMs: number;
|
||||
counts?: KoboSyncHistoryCounts;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KoboSyncHistoryService {
|
||||
private readonly logger = new Logger(KoboSyncHistoryService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly bookAccessService: KoboBookAccessService,
|
||||
) {}
|
||||
|
||||
async listForUser(userId: number, limit = DEFAULT_LIST_LIMIT): Promise<KoboSyncHistoryEntry[]> {
|
||||
const cappedLimit = Math.min(Math.max(Math.trunc(limit), 1), HISTORY_LIMIT);
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: schema.koboSyncHistory.id,
|
||||
deviceId: schema.koboSyncHistory.deviceId,
|
||||
deviceName: schema.koboDevices.name,
|
||||
event: schema.koboSyncHistory.event,
|
||||
status: schema.koboSyncHistory.status,
|
||||
counts: schema.koboSyncHistory.counts,
|
||||
durationMs: schema.koboSyncHistory.durationMs,
|
||||
errorClass: schema.koboSyncHistory.errorClass,
|
||||
error: schema.koboSyncHistory.error,
|
||||
createdAt: schema.koboSyncHistory.createdAt,
|
||||
})
|
||||
.from(schema.koboSyncHistory)
|
||||
.leftJoin(schema.koboDevices, eq(schema.koboDevices.id, schema.koboSyncHistory.deviceId))
|
||||
.where(eq(schema.koboSyncHistory.userId, userId))
|
||||
.orderBy(desc(schema.koboSyncHistory.createdAt), desc(schema.koboSyncHistory.id))
|
||||
.limit(cappedLimit);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
deviceId: row.deviceId,
|
||||
deviceName: row.deviceName,
|
||||
event: row.event as KoboSyncHistoryEvent,
|
||||
status: row.status as KoboSyncHistoryEntry['status'],
|
||||
counts: this.asCounts(row.counts),
|
||||
durationMs: row.durationMs,
|
||||
errorClass: row.errorClass,
|
||||
error: row.error,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async recordSuccess(input: RecordBaseInput): Promise<void> {
|
||||
await this.record({
|
||||
...input,
|
||||
status: 'success',
|
||||
errorClass: null,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
async recordFailure(input: RecordBaseInput, error: unknown): Promise<void> {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
await this.record({
|
||||
...input,
|
||||
status: 'failed',
|
||||
errorClass: err.constructor.name,
|
||||
error: sanitizeLogValue(err.message, 500),
|
||||
});
|
||||
}
|
||||
|
||||
async countsForBook(userId: number, bookId: number, counts: KoboSyncHistoryCounts = {}): Promise<KoboSyncHistoryCounts> {
|
||||
let bookTitle: string | null;
|
||||
try {
|
||||
await this.bookAccessService.assertBookAccessible(userId, bookId);
|
||||
const [book] = await this.db
|
||||
.select({ title: schema.bookMetadata.title })
|
||||
.from(schema.books)
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(eq(schema.books.id, bookId))
|
||||
.limit(1);
|
||||
bookTitle = book?.title ?? null;
|
||||
} catch {
|
||||
bookTitle = null;
|
||||
}
|
||||
|
||||
return {
|
||||
...counts,
|
||||
bookId,
|
||||
bookTitle,
|
||||
};
|
||||
}
|
||||
|
||||
private async record(input: RecordBaseInput & { status: 'success' | 'failed'; errorClass: string | null; error: string | null }): Promise<void> {
|
||||
try {
|
||||
await this.db.insert(schema.koboSyncHistory).values({
|
||||
userId: input.userId,
|
||||
deviceId: input.deviceId,
|
||||
event: input.event,
|
||||
status: input.status,
|
||||
counts: input.counts ?? {},
|
||||
durationMs: Math.max(0, Math.trunc(input.durationMs)),
|
||||
errorClass: input.errorClass,
|
||||
error: input.error,
|
||||
});
|
||||
await this.pruneUserHistory(input.userId);
|
||||
} catch (error: unknown) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.warn(
|
||||
`[kobo.sync_history] [fail] userId=${input.userId} durationMs=0 errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - failed to record kobo sync history`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneUserHistory(userId: number): Promise<void> {
|
||||
await this.db.execute(sql`
|
||||
DELETE FROM ${schema.koboSyncHistory}
|
||||
WHERE ${schema.koboSyncHistory.userId} = ${userId}
|
||||
AND ${schema.koboSyncHistory.id} NOT IN (
|
||||
SELECT ${schema.koboSyncHistory.id}
|
||||
FROM ${schema.koboSyncHistory}
|
||||
WHERE ${schema.koboSyncHistory.userId} = ${userId}
|
||||
ORDER BY ${schema.koboSyncHistory.createdAt} DESC, ${schema.koboSyncHistory.id} DESC
|
||||
LIMIT ${HISTORY_LIMIT}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private asCounts(value: unknown): KoboSyncHistoryCounts {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return value as KoboSyncHistoryCounts;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user