From 70e76600e0afccf01811ac87afab05479a5b774b Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:48:11 -0600 Subject: [PATCH] feat(server): add Booklore migration with source adapter and executor --- .husky/pre-commit | 6 + .husky/pre-push | 6 + .../components/__tests__/DetailsTab.spec.ts | 33 +- .../components/detail/tabs/DetailsTab.vue | 3 +- .../components/AddToCollectionSheet.vue | 13 +- .../components/CreateCollectionDialog.vue | 19 +- .../components/EditCollectionDialog.vue | 33 +- .../__tests__/useCollections.spec.ts | 68 + .../collection/composables/useCollections.ts | 13 +- .../features/migration/lib/migration-api.ts | 266 ++++ .../__tests__/useFoliateInput.spec.ts | 32 +- .../__tests__/useFoliateSelection.spec.ts | 4 +- .../composables/__tests__/useSearch.spec.ts | 8 +- .../src/features/reader/pdf/PdfReaderView.vue | 5 +- .../features/settings/MigrationSettings.vue | 1193 +++++++++++++++++ .../src/features/settings/SettingsHeader.vue | 3 + client/src/router/index.ts | 6 + client/src/views/CollectionView.vue | 22 +- server/package.json | 21 +- server/src/app.module.ts | 2 + server/src/db/migrations/0000_baseline.sql | 90 ++ .../src/db/migrations/meta/0000_snapshot.json | 712 +++++++++- server/src/db/migrations/meta/_journal.json | 2 +- server/src/db/schema/index.ts | 1 + server/src/db/schema/migration.ts | 146 ++ server/src/db/schema/schema.test.ts | 6 + .../architecture-boundaries.test.ts | 2 + .../auth/oidc/oidc-session.repository.test.ts | 1 + .../modules/catalog/catalog.service.test.ts | 1 + .../modules/collection/collection.dto.test.ts | 4 +- .../modules/collection/collection.service.ts | 2 +- .../collection/dto/create-collection.dto.ts | 10 +- .../email/email-recipient.repository.test.ts | 1 + .../booklore-connection-config.test.ts | 30 + .../booklore/booklore-connection-config.ts | 38 + .../adapters/booklore/booklore-connector.ts | 65 + .../booklore/booklore-source.adapter.test.ts | 29 + .../booklore/booklore-source.adapter.ts | 1076 +++++++++++++++ .../adapters/source-adapter.registry.ts | 27 + .../adapters/source-adapter.types.ts | 158 +++ .../src/modules/migration/core/hash.test.ts | 17 + server/src/modules/migration/core/hash.ts | 18 + .../modules/migration/core/plan-hash.test.ts | 56 + .../src/modules/migration/core/plan-hash.ts | 46 + .../migration/dto/create-dry-run-plan.dto.ts | 13 + .../dto/create-migration-profile.dto.ts | 53 + .../dto/create-migration-source.dto.ts | 16 + .../migration/dto/start-live-run.dto.ts | 14 + .../dto/test-migration-source.dto.ts | 11 + .../dto/validate-path-mappings.dto.ts | 28 + .../migration-executor.service.test.ts | 381 ++++++ .../executor/migration-executor.service.ts | 1146 ++++++++++++++++ .../modules/migration/migration.controller.ts | 95 ++ .../migration/migration.module.test.ts | 33 + .../src/modules/migration/migration.module.ts | 31 + .../modules/migration/migration.repository.ts | 266 ++++ .../migration/migration.service.test.ts | 251 ++++ .../modules/migration/migration.service.ts | 576 ++++++++ .../planner/matching.service.test.ts | 32 + .../migration/planner/matching.service.ts | 217 +++ .../path-mapping-validation.service.test.ts | 88 ++ .../path-mapping-validation.service.ts | 155 +++ .../migration/planner/planner.service.ts | 207 +++ .../migration/planner/planner.types.ts | 79 ++ .../reporting/migration-reporting.service.ts | 140 ++ 65 files changed, 8068 insertions(+), 58 deletions(-) create mode 100644 client/src/features/collection/composables/__tests__/useCollections.spec.ts create mode 100644 client/src/features/migration/lib/migration-api.ts create mode 100644 client/src/features/settings/MigrationSettings.vue create mode 100644 server/src/db/schema/migration.ts create mode 100644 server/src/modules/migration/adapters/booklore/booklore-connection-config.test.ts create mode 100644 server/src/modules/migration/adapters/booklore/booklore-connection-config.ts create mode 100644 server/src/modules/migration/adapters/booklore/booklore-connector.ts create mode 100644 server/src/modules/migration/adapters/booklore/booklore-source.adapter.test.ts create mode 100644 server/src/modules/migration/adapters/booklore/booklore-source.adapter.ts create mode 100644 server/src/modules/migration/adapters/source-adapter.registry.ts create mode 100644 server/src/modules/migration/adapters/source-adapter.types.ts create mode 100644 server/src/modules/migration/core/hash.test.ts create mode 100644 server/src/modules/migration/core/hash.ts create mode 100644 server/src/modules/migration/core/plan-hash.test.ts create mode 100644 server/src/modules/migration/core/plan-hash.ts create mode 100644 server/src/modules/migration/dto/create-dry-run-plan.dto.ts create mode 100644 server/src/modules/migration/dto/create-migration-profile.dto.ts create mode 100644 server/src/modules/migration/dto/create-migration-source.dto.ts create mode 100644 server/src/modules/migration/dto/start-live-run.dto.ts create mode 100644 server/src/modules/migration/dto/test-migration-source.dto.ts create mode 100644 server/src/modules/migration/dto/validate-path-mappings.dto.ts create mode 100644 server/src/modules/migration/executor/migration-executor.service.test.ts create mode 100644 server/src/modules/migration/executor/migration-executor.service.ts create mode 100644 server/src/modules/migration/migration.controller.ts create mode 100644 server/src/modules/migration/migration.module.test.ts create mode 100644 server/src/modules/migration/migration.module.ts create mode 100644 server/src/modules/migration/migration.repository.ts create mode 100644 server/src/modules/migration/migration.service.test.ts create mode 100644 server/src/modules/migration/migration.service.ts create mode 100644 server/src/modules/migration/planner/matching.service.test.ts create mode 100644 server/src/modules/migration/planner/matching.service.ts create mode 100644 server/src/modules/migration/planner/path-mapping-validation.service.test.ts create mode 100644 server/src/modules/migration/planner/path-mapping-validation.service.ts create mode 100644 server/src/modules/migration/planner/planner.service.ts create mode 100644 server/src/modules/migration/planner/planner.types.ts create mode 100644 server/src/modules/migration/reporting/migration-reporting.service.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 5ee7abd87..40b568f76 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,7 @@ +# Load nvm/pnpm if not in PATH (needed for GUI clients like Sourcetree) +export PATH="$PATH:/usr/local/bin" +export PNPM_HOME="$HOME/Library/pnpm" +export PATH="$PNPM_HOME:$PATH" +[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh" + pnpm exec lint-staged diff --git a/.husky/pre-push b/.husky/pre-push index e01991635..7d7fc4516 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1,7 @@ +# Load nvm/pnpm if not in PATH (needed for GUI clients like Sourcetree) +export PATH="$PATH:/usr/local/bin" +export PNPM_HOME="$HOME/Library/pnpm" +export PATH="$PNPM_HOME:$PATH" +[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh" + pnpm run verify:fast diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts index cb0d64980..5aa8a4b94 100644 --- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { describe, it, expect, vi, beforeEach } from 'vitest' beforeEach(() => { @@ -13,6 +13,7 @@ beforeEach(() => { }) import DetailsTab from '../detail/tabs/DetailsTab.vue' import type { BookDetail } from '@projectx/types' +import { api } from '@/lib/api' vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal() @@ -92,6 +93,13 @@ function makeBook(overrides: Partial = {}): BookDetail { } } +function makeApiResponse(data: unknown, ok = true): Response { + return { + ok, + json: async () => data, + } as Response +} + describe('DetailsTab — missing state', () => { it('renders the amber warning banner', () => { const wrapper = mount(DetailsTab, { @@ -146,4 +154,27 @@ describe('DetailsTab — present state', () => { expect(wrapper.find('a[title="Open in Goodreads"]').exists()).toBe(true) expect(wrapper.text()).not.toContain('Info Links') }) + + it('renders only collections that already contain the book', async () => { + vi.mocked(api).mockImplementation(async (input) => { + if (input === '/api/v1/collections?bookIds=1') { + return makeApiResponse([ + { id: 10, name: 'Favorites', syncToKobo: false, memberCount: 1 }, + { id: 11, name: 'Want to Read', syncToKobo: true, memberCount: 0 }, + ]) + } + + return makeApiResponse({}, false) + }) + + const wrapper = mount(DetailsTab, { + props: { book: makeBook({ id: 1 }) }, + global: globalStubs, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('Favorites') + expect(wrapper.text()).not.toContain('Want to Read') + }) }) diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index 3e4ad765d..ba4e6cc6e 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -605,7 +605,8 @@ async function loadSupplemental() { audiobookProgress.value = null } - collections.value = collectionsRes.ok ? ((await collectionsRes.json()) as CollectionMembership[]) : [] + const fetchedCollections = collectionsRes.ok ? ((await collectionsRes.json()) as CollectionMembership[]) : [] + collections.value = fetchedCollections.filter((collection) => (collection.memberCount ?? 0) > 0) if (canViewKobo.value) { const fallbackSyncCollections = collections.value.filter((c) => c.syncToKobo && (c.memberCount ?? 0) > 0).map((c) => c.name) diff --git a/client/src/features/collection/components/AddToCollectionSheet.vue b/client/src/features/collection/components/AddToCollectionSheet.vue index 4e0e90dfe..f6f0754b7 100644 --- a/client/src/features/collection/components/AddToCollectionSheet.vue +++ b/client/src/features/collection/components/AddToCollectionSheet.vue @@ -6,6 +6,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCollections } from '../composables/useCollections' +import IconPicker from '@/components/IconPicker.vue' import type { Collection } from '@projectx/types' const props = defineProps<{ @@ -23,6 +24,7 @@ const { fetchCollectionsWithMembership, createCollection, addBooksToCollection } const localCollections = ref([]) const addedIds = ref>(new Set()) const newName = ref('') +const newIcon = ref('') const creating = ref(false) const addingTo = ref(null) @@ -52,11 +54,13 @@ function partialCount(collection: Collection): number { async function handleCreate() { const name = newName.value.trim() - if (!name) return + const icon = newIcon.value.trim() + if (!name || !icon) return creating.value = true try { - const collection = await createCollection(name) + const collection = await createCollection(name, icon) newName.value = '' + newIcon.value = '' localCollections.value = [...localCollections.value, collection] try { await addBooksToCollection(collection.id, props.bookIds) @@ -119,9 +123,10 @@ function handleDone() {

New Collection

-
+
-