mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
chore: implement local quality foundation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
_
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
HUSKY_SH="$(dirname -- "$0")/_/husky.sh"
|
||||
[ -f "$HUSKY_SH" ] && . "$HUSKY_SH"
|
||||
|
||||
pnpm exec lint-staged
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
HUSKY_SH="$(dirname -- "$0")/_/husky.sh"
|
||||
[ -f "$HUSKY_SH" ] && . "$HUSKY_SH"
|
||||
|
||||
pnpm run quick
|
||||
@@ -0,0 +1,52 @@
|
||||
# Contributing
|
||||
|
||||
## Daily Workflow
|
||||
|
||||
Use this command surface:
|
||||
|
||||
1. `pnpm setup` (once per machine)
|
||||
2. `pnpm dev` (daily coding loop)
|
||||
3. `pnpm verify` (before push)
|
||||
|
||||
Optional:
|
||||
|
||||
1. `pnpm quick` (faster checks while coding)
|
||||
2. `pnpm verify:strict` (aspirational strict gate while we burn down baseline debt)
|
||||
3. `pnpm guide` (show command reference)
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Before opening or updating a PR:
|
||||
|
||||
1. `pnpm verify` must pass
|
||||
2. `pnpm verify:strict` is currently optional until formatting and full client typecheck baseline debt is reduced.
|
||||
3. If tests touched critical flows, run `pnpm test:e2e:smoke`
|
||||
4. If coverage-sensitive changes are made, run `pnpm coverage`
|
||||
|
||||
## Check vs Fix Commands
|
||||
|
||||
Non-mutating:
|
||||
|
||||
1. `pnpm format:check`
|
||||
2. `pnpm lint:check`
|
||||
3. `pnpm typecheck`
|
||||
4. `pnpm typecheck:full`
|
||||
5. `pnpm typecheck:client:baseline:update` (update accepted client TS baseline after intentional changes)
|
||||
|
||||
Mutating:
|
||||
|
||||
1. `pnpm format`
|
||||
2. `pnpm lint:fix`
|
||||
|
||||
## Database Commands
|
||||
|
||||
1. `pnpm db:up`
|
||||
2. `pnpm db:migrate`
|
||||
3. `pnpm db:seed`
|
||||
4. `pnpm db:reset`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. Run `pnpm doctor` for environment checks.
|
||||
2. If dependencies changed, run `pnpm install`.
|
||||
3. If local DB state is inconsistent, run `pnpm db:reset`.
|
||||
@@ -11,7 +11,7 @@ A book and library management app with Kobo device support. Multi-user, self-hos
|
||||
### Prerequisites
|
||||
|
||||
| Tool | Version | Install |
|
||||
|---------|---------|--------------------------------------------------------------|
|
||||
| ------- | ------- | ------------------------------------------------------------ |
|
||||
| Node.js | >= 24 | [nodejs.org](https://nodejs.org) |
|
||||
| pnpm | >= 9 | `corepack enable && corepack prepare pnpm@latest --activate` |
|
||||
| Docker | latest | [docker.com](https://www.docker.com/products/docker-desktop) |
|
||||
@@ -21,10 +21,7 @@ A book and library management app with Kobo device support. Multi-user, self-hos
|
||||
```bash
|
||||
git clone <repo-url> projectx
|
||||
cd projectx
|
||||
docker compose up -d --wait # start Postgres
|
||||
cp server/.env.example server/.env # create env config (edit if needed)
|
||||
pnpm install # install all dependencies
|
||||
cd server && pnpm db:migrate && cd .. # apply database migrations
|
||||
pnpm setup
|
||||
```
|
||||
|
||||
Docker is only used for PostgreSQL. The server and client run directly on your machine.
|
||||
@@ -55,8 +52,8 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to
|
||||
### First steps after setup
|
||||
|
||||
1. **Log in** - a default admin account is created automatically:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
2. **Change password** - you'll be prompted to set a new password on first login. Use `Admin123!` for local dev.
|
||||
3. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files.
|
||||
4. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
|
||||
@@ -95,40 +92,58 @@ This is a **pnpm workspace**. The three packages are:
|
||||
|
||||
All commands run from the **repo root** unless noted otherwise.
|
||||
|
||||
### Everyday workflow
|
||||
|
||||
| Command | Description |
|
||||
| -------------------- | -------------------------------------------------- |
|
||||
| `pnpm setup` | One-time bootstrap |
|
||||
| `pnpm dev` | Daily development |
|
||||
| `pnpm verify` | Default local checks before push |
|
||||
| `pnpm quick` | Faster local checks while coding |
|
||||
| `pnpm verify:strict` | Aspirational strict gate (format + full typecheck) |
|
||||
| `pnpm guide` | Print command reference |
|
||||
|
||||
### Development
|
||||
|
||||
| Command | Description |
|
||||
|-------------------------------|--------------------------------------|
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| `pnpm dev` | Start server + client (concurrently) |
|
||||
| `cd server && pnpm start:dev` | Server only (watch mode) |
|
||||
| `cd client && pnpm dev` | Client only (Vite dev server) |
|
||||
|
||||
### Testing
|
||||
|
||||
| Command | Description |
|
||||
|----------------------------------------------------|---------------------------|
|
||||
| `cd server && pnpm test` | Server unit tests |
|
||||
| `cd server && pnpm test -- --testPathPattern=scan` | Run matching server tests |
|
||||
| `cd client && pnpm test:unit` | Client unit tests |
|
||||
| `cd client && pnpm test:unit -- BookCover` | Run matching client tests |
|
||||
| `cd server && pnpm test:e2e` | Server E2E tests |
|
||||
| Command | Description |
|
||||
| ------------------------------- | ---------------------------- |
|
||||
| `pnpm test` | Server + client unit tests |
|
||||
| `pnpm test:server` | Server unit tests |
|
||||
| `pnpm test:client` | Client unit tests |
|
||||
| `pnpm test:e2e:smoke` | Server smoke e2e |
|
||||
| `pnpm coverage` | Coverage for server + client |
|
||||
| `pnpm --filter server test:e2e` | Server e2e only |
|
||||
|
||||
### Database
|
||||
|
||||
| Command | Description |
|
||||
|----------------------------------------|------------------------------------------|
|
||||
| `cd server && pnpm db:migrate` | Apply pending migrations |
|
||||
| `cd server && pnpm db:generate <name>` | Generate a migration from schema changes |
|
||||
| `cd server && pnpm db:studio` | Open Drizzle Studio (DB browser) |
|
||||
| Command | Description |
|
||||
| ----------------------------------------- | ---------------------------------------- |
|
||||
| `pnpm db:up` | Start local Postgres |
|
||||
| `pnpm db:migrate` | Apply pending migrations |
|
||||
| `pnpm db:seed` | Seed baseline app data |
|
||||
| `pnpm db:reset` | Reset schema, migrate, and seed |
|
||||
| `pnpm --filter server db:generate <name>` | Generate a migration from schema changes |
|
||||
| `pnpm --filter server db:studio` | Open Drizzle Studio (DB browser) |
|
||||
|
||||
### Linting & formatting
|
||||
|
||||
| Command | Description |
|
||||
|---------------------------------------|--------------------|
|
||||
| `cd server && npx prettier --write .` | Format server code |
|
||||
| `cd client && npx prettier --write .` | Format client code |
|
||||
| `cd server && pnpm lint` | Lint server |
|
||||
| `cd client && pnpm lint` | Lint client |
|
||||
| Command | Description |
|
||||
| --------------------------------------- | ------------------------------------------ |
|
||||
| `pnpm format:check` | Check formatting |
|
||||
| `pnpm format` | Auto-format source files |
|
||||
| `pnpm lint:check` | Non-mutating lint checks |
|
||||
| `pnpm lint:fix` | Auto-fix lint issues |
|
||||
| `pnpm typecheck` | Server typecheck + no-new client TS errors |
|
||||
| `pnpm typecheck:full` | Typecheck server + client (strict) |
|
||||
| `pnpm typecheck:client:baseline:update` | Refresh accepted client TS baseline |
|
||||
|
||||
---
|
||||
|
||||
@@ -162,7 +177,7 @@ If other people have pushed changes, you may need to sync dependencies and migra
|
||||
|
||||
```bash
|
||||
pnpm install # pick up lockfile changes
|
||||
cd server && pnpm db:migrate # apply any new migrations
|
||||
pnpm db:migrate # apply any new migrations
|
||||
```
|
||||
|
||||
### Adding a dependency
|
||||
@@ -189,7 +204,7 @@ The fastest way to learn the patterns is to follow an existing module:
|
||||
Server environment is configured in `server/.env` (created from `.env.example` during setup).
|
||||
|
||||
| Variable | Default | Description |
|
||||
|--------------------------|--------------------------------------------------------|------------------------------------------------------------|
|
||||
| ------------------------ | ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://projectx:projectx@localhost:5432/projectx` | PostgreSQL connection string |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
@@ -209,31 +224,17 @@ The database schema is defined in `server/src/db/schema/` using Drizzle ORM. Eac
|
||||
**Making schema changes:**
|
||||
|
||||
1. Edit the relevant file in `server/src/db/schema/`
|
||||
2. Generate a migration: `cd server && pnpm db:generate describe_your_change`
|
||||
3. Apply it: `cd server && pnpm db:migrate`
|
||||
2. Generate a migration: `pnpm --filter server db:generate describe_your_change`
|
||||
3. Apply it: `pnpm db:migrate`
|
||||
|
||||
Never hand-write migration SQL. Always generate from schema diffs.
|
||||
|
||||
### Resetting the database from scratch
|
||||
|
||||
```bash
|
||||
# 1. Drop and recreate the public schema
|
||||
docker exec $(docker ps --filter "name=postgres" --format "{{.Names}}" | head -1) \
|
||||
psql -U projectx -d projectx -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
|
||||
# 2. Clear Drizzle's migration tracking (it lives in a separate schema)
|
||||
docker exec $(docker ps --filter "name=postgres" --format "{{.Names}}" | head -1) \
|
||||
psql -U projectx -d projectx -c "DELETE FROM drizzle.__drizzle_migrations;"
|
||||
|
||||
# 3. Re-apply all migrations
|
||||
cd server && pnpm db:migrate
|
||||
|
||||
# 4. (Optional) Wipe cover images
|
||||
rm -rf local/data/covers
|
||||
pnpm db:reset
|
||||
```
|
||||
|
||||
Step 2 is necessary because Drizzle tracks applied migrations in `drizzle.__drizzle_migrations`, which lives outside the `public` schema and survives the drop.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
@@ -264,7 +265,7 @@ Another PostgreSQL instance is running. Either stop it or change the port mappin
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "5433:5432" # use 5433 on host
|
||||
- "5433:5432" # use 5433 on host
|
||||
```
|
||||
|
||||
Then update `DATABASE_URL` in `server/.env` to use port `5433`.
|
||||
@@ -325,8 +326,7 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000)
|
||||
## Further Reading
|
||||
|
||||
| Doc | What it covers |
|
||||
|----------------------------------------|--------------------------------------------------------------|
|
||||
| -------------------------------------- | ------------------------------------------------------------ |
|
||||
| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions |
|
||||
| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions |
|
||||
| [`packages/types/`](packages/types/) | Shared type definitions between server and client |
|
||||
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@ src/
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pnpm test:unit # run Vitest
|
||||
pnpm test:unit # run unit tests
|
||||
pnpm test:cov # run unit tests with coverage
|
||||
```
|
||||
|
||||
## Key conventions
|
||||
|
||||
+28
-21
@@ -8,57 +8,64 @@
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:cov": "vitest --coverage",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
"lint": "pnpm run lint:check",
|
||||
"lint:check": "run-s lint:oxlint:check lint:eslint:check",
|
||||
"lint:fix": "run-s lint:oxlint:fix lint:eslint:fix",
|
||||
"lint:oxlint:check": "oxlint src",
|
||||
"lint:oxlint:fix": "oxlint src --fix",
|
||||
"lint:eslint:check": "eslint src --cache",
|
||||
"lint:eslint:fix": "eslint src --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/",
|
||||
"format:check": "prettier --check src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/fraunces": "^5.2.9",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@projectx/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.564.0",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.8.0",
|
||||
"reka-ui": "^2.9.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.28",
|
||||
"vue-router": "^5.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^5.0.3",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/jsdom": "^28.0.0",
|
||||
"@types/node": "^24.11.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitest/eslint-plugin": "^1.6.7",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/eslint-plugin": "^1.6.9",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.46.0",
|
||||
"eslint-plugin-oxlint": "~1.50.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.47.0",
|
||||
"oxlint": "~1.50.0",
|
||||
"prettier": "3.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CheckSquare, LayoutGrid, List, MoreHorizontal, SlidersHorizontal, Squar
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -64,7 +63,11 @@ function getIconComponent(name: string) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="hidden md:flex h-8 gap-1.5 text-[11px] font-bold uppercase tracking-tight px-2.5 rounded-lg transition-all"
|
||||
:class="selectionMode ? 'text-primary bg-primary/10 hover:bg-primary/20 ring-1 ring-primary/20' : 'text-muted-foreground/60 hover:text-foreground hover:bg-primary/5'"
|
||||
:class="
|
||||
selectionMode
|
||||
? 'text-primary bg-primary/10 hover:bg-primary/20 ring-1 ring-primary/20'
|
||||
: 'text-muted-foreground/60 hover:text-foreground hover:bg-primary/5'
|
||||
"
|
||||
@click="emit('toggle-selection')"
|
||||
>
|
||||
<CheckSquare v-if="selectionMode" :size="13" />
|
||||
@@ -101,7 +104,11 @@ function getIconComponent(name: string) {
|
||||
<!-- Desktop: display settings popover -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8 rounded-lg text-muted-foreground/50 hover:text-foreground hover:bg-primary/5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hidden md:flex h-8 w-8 rounded-lg text-muted-foreground/50 hover:text-foreground hover:bg-primary/5"
|
||||
>
|
||||
<SlidersHorizontal :size="14" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { OidcPublicConfig } from '@projectx/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
||||
const array = new Uint8Array(32)
|
||||
|
||||
@@ -104,8 +104,12 @@ function openFile(file: BookFileRef) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors cursor-pointer hover:bg-muted/50"
|
||||
:class="[selectionMode ? 'cursor-pointer select-none' : '', selected ? 'bg-primary/8 ring-1 ring-primary/30' : '']"
|
||||
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors cursor-pointer"
|
||||
:class="[
|
||||
selectionMode ? 'cursor-pointer select-none' : '',
|
||||
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
|
||||
isMissing ? 'grayscale opacity-60' : 'hover:bg-muted/50',
|
||||
]"
|
||||
@click="selectionMode ? emit('select', $event) : emit('action', 'quick-view')"
|
||||
>
|
||||
<!-- Selection checkbox -->
|
||||
@@ -160,11 +164,7 @@ function openFile(file: BookFileRef) {
|
||||
<div class="hidden sm:flex items-center gap-0.5" @mouseleave="hoverRating = null">
|
||||
<Tooltip v-for="star in 5" :key="star">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-0.5 transition-colors"
|
||||
@mouseenter="hoverRating = star"
|
||||
@click="setRating(star)"
|
||||
>
|
||||
<button class="p-0.5 transition-colors" @mouseenter="hoverRating = star" @click="setRating(star)">
|
||||
<Star class="size-3" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40'" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import BookCoverCard from '../BookCoverCard.vue'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return { ...actual, useRouter: () => ({ push: vi.fn() }) }
|
||||
})
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
|
||||
}))
|
||||
@@ -30,6 +33,13 @@ const missingBook: BookCard = {
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const presentBook: BookCard = {
|
||||
@@ -40,6 +50,13 @@ const presentBook: BookCard = {
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [{ id: 10, format: 'epub', role: 'primary' }],
|
||||
publishedYear: 2024,
|
||||
language: 'en',
|
||||
genres: [],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
describe('BookCoverCard — missing state', () => {
|
||||
@@ -66,7 +83,8 @@ describe('BookCoverCard — missing state', () => {
|
||||
|
||||
it('uses cursor-default on the root when book is missing', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
|
||||
expect(wrapper.classes()).toContain('cursor-default')
|
||||
const root = wrapper.find('div.group')
|
||||
expect(root.classes()).toContain('cursor-default')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import BookListRow from '../BookListRow.vue'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return { ...actual, useRouter: () => ({ push: vi.fn() }) }
|
||||
})
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
|
||||
}))
|
||||
@@ -18,6 +21,9 @@ const globalStubs = {
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<div />' },
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +35,13 @@ const missingBook: BookCard = {
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const presentBook: BookCard = {
|
||||
@@ -39,6 +52,13 @@ const presentBook: BookCard = {
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [{ id: 10, format: 'epub', role: 'primary' }],
|
||||
publishedYear: 2024,
|
||||
language: 'en',
|
||||
genres: ['Fiction'],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
describe('BookListRow — missing state', () => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import DetailsTab from '../detail/tabs/DetailsTab.vue'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }))
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return { ...actual, useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }
|
||||
})
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ hasPermission: () => false }),
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { Search, Loader2, X, Image as ImageIcon, ExternalLink, Check } from 'lucide-vue-next'
|
||||
import { Search, Loader2, X, Image as ImageIcon, Check } from 'lucide-vue-next'
|
||||
import type { CoverSearchResult } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -122,7 +122,9 @@ onMounted(() => {
|
||||
<img :src="res.previewUrl" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<div class="p-2 rounded-full bg-primary text-white scale-75 group-hover:scale-100 transition-transform">
|
||||
<Check class="size-5" />
|
||||
</div>
|
||||
@@ -130,8 +132,10 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Resolution Badge -->
|
||||
<div class="absolute top-2 right-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white backdrop-blur-md shadow-sm"
|
||||
:class="res.width >= 1000 ? 'bg-green-500/80' : 'bg-black/50'">
|
||||
<div
|
||||
class="absolute top-2 right-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white backdrop-blur-md shadow-sm"
|
||||
:class="res.width >= 1000 ? 'bg-green-500/80' : 'bg-black/50'"
|
||||
>
|
||||
{{ res.width }}x{{ res.height }}
|
||||
</div>
|
||||
|
||||
@@ -182,10 +186,10 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.2);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const bookIds = ref<number[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
@@ -75,9 +75,17 @@ function handleImageClick(e: MouseEvent) {
|
||||
return
|
||||
}
|
||||
const x = e.clientX / window.innerWidth
|
||||
if (x < 0.25) direction.value === 'rtl' ? nextPage() : prevPage()
|
||||
else if (x > 0.75) direction.value === 'rtl' ? prevPage() : nextPage()
|
||||
else handleMiddleTap()
|
||||
if (x < 0.25) {
|
||||
if (direction.value === 'rtl') nextPage()
|
||||
else prevPage()
|
||||
return
|
||||
}
|
||||
if (x > 0.75) {
|
||||
if (direction.value === 'rtl') prevPage()
|
||||
else nextPage()
|
||||
return
|
||||
}
|
||||
handleMiddleTap()
|
||||
}
|
||||
|
||||
// ── Touch / swipe ──────────────────────────────────────────────────────────────
|
||||
@@ -90,8 +98,13 @@ function onTouchStart(e: TouchEvent) {
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX
|
||||
if (Math.abs(dx) < 50) return
|
||||
if (dx < 0) direction.value === 'rtl' ? prevPage() : nextPage()
|
||||
else direction.value === 'rtl' ? nextPage() : prevPage()
|
||||
if (dx < 0) {
|
||||
if (direction.value === 'rtl') prevPage()
|
||||
else nextPage()
|
||||
return
|
||||
}
|
||||
if (direction.value === 'rtl') nextPage()
|
||||
else prevPage()
|
||||
}
|
||||
|
||||
// ── Wheel (paginated mode only) ────────────────────────────────────────────────
|
||||
@@ -111,16 +124,19 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
e.preventDefault()
|
||||
isRtl ? prevPage() : nextPage()
|
||||
if (isRtl) prevPage()
|
||||
else nextPage()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
e.preventDefault()
|
||||
isRtl ? nextPage() : prevPage()
|
||||
if (isRtl) nextPage()
|
||||
else prevPage()
|
||||
break
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
e.shiftKey ? prevPage() : nextPage()
|
||||
if (e.shiftKey) prevPage()
|
||||
else nextPage()
|
||||
break
|
||||
case 'Home':
|
||||
goToPage(0)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
matchCount: number
|
||||
currentIndex: number
|
||||
}>()
|
||||
@@ -42,10 +42,7 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 shrink-0"
|
||||
style="background: rgba(60, 65, 70, 1); border-bottom: 1px solid rgba(0,0,0,0.3)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 shrink-0" style="background: rgba(60, 65, 70, 1); border-bottom: 1px solid rgba(0, 0, 0, 0.3)">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
@@ -53,8 +50,8 @@ defineExpose({
|
||||
placeholder="Find in document..."
|
||||
class="flex-1 min-w-0 bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30"
|
||||
style="max-width: 260px"
|
||||
@keydown.enter="emit('next')"
|
||||
@keydown.shift.enter.prevent="emit('prev')"
|
||||
@keydown.enter.exact="emit('next')"
|
||||
@keydown.enter.shift.exact.prevent="emit('prev')"
|
||||
@keydown.escape="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -95,4 +92,3 @@ defineExpose({
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export interface Bookmark {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type * as pdfjsLib from 'pdfjs-dist'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
|
||||
@@ -39,4 +39,3 @@ export function usePdfThumbnails(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy |
|
||||
|
||||
return { thumbnails, loadThumbnail, loadRange }
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function useReaderSettings(bookFileId: number, format: string) {
|
||||
() =>
|
||||
({
|
||||
...(READER_GROUP_DEFAULTS[group] as ReaderSettings),
|
||||
...(defaultSettings.value ?? {}),
|
||||
...(bookDelta.value ?? {}),
|
||||
...(defaultSettings.value ?? undefined),
|
||||
...(bookDelta.value ?? undefined),
|
||||
}) as ReaderSettings,
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ export function useReaderSettings(bookFileId: number, format: string) {
|
||||
|
||||
// Merges only the changed field(s) into the existing delta — never saves a full snapshot.
|
||||
function updateBookSettings(patch: Partial<ReaderSettings>) {
|
||||
const next = { ...(bookDelta.value ?? {}), ...patch } as Partial<ReaderSettings>
|
||||
const next = { ...(bookDelta.value ?? undefined), ...patch } as Partial<ReaderSettings>
|
||||
bookDelta.value = next
|
||||
isCustomized.value = Object.keys(next).length > 0
|
||||
writeLs(lsBookKey(bookFileId), next)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { StagingFile, StagingMetadata } from '@projectx/types'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { StagingFile, StagingFilesPage, StagingFileStatus } from '@projectx/types'
|
||||
|
||||
@@ -142,4 +142,3 @@ export function useStagingFiles() {
|
||||
getSelectionPayload,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*", "eslint.config.*"],
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "eslint.config.*"],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Client typecheck baseline
|
||||
# Auto-generated by: pnpm run typecheck:client:baseline:update
|
||||
# One diagnostic per line. Do not edit manually unless necessary.
|
||||
src/components/IconPicker.vue(174,39): error TS18046: 'item' is of type 'unknown'.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/library/components/LibraryCreatorDetails.vue(109,43): error TS18046: 'item' is of type 'unknown'.
|
||||
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/components/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/components/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
|
||||
src/features/reader/components/PdfToolbar.vue(216,93): error TS2769: No overload matches this call.
|
||||
src/features/reader/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/features/reader/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/composables/usePdfOutline.ts(22,72): error TS2694: Namespace '"node_modules/.pnpm/pdfjs-dist@5.5.207/node_modules/pdfjs-dist/types/src/pdf"' has no exported member 'RefProxy'.
|
||||
src/features/reader/composables/usePdfOutline.ts(29,74): error TS2694: Namespace '"node_modules/.pnpm/pdfjs-dist@5.5.207/node_modules/pdfjs-dist/types/src/pdf"' has no exported member 'OutlineNode'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(96,17): error TS18048: 'e' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(97,34): error TS18048: 'f' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(98,31): error TS18048: 'a' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(98,50): error TS18048: 'b' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfThumbnails.ts(23,25): error TS2345: Argument of type '{ canvasContext: CanvasRenderingContext2D; viewport: PageViewport; }' is not assignable to parameter of type 'RenderParameters'.
|
||||
src/features/reader/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
|
||||
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
|
||||
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
|
||||
src/features/reader/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue(130,25): error TS2352: Conversion of type '{ enabled: boolean; apiKey: string; } | { enabled: boolean; domain: string; cookie: string; } | { enabled: boolean; } | { enabled: boolean; apiKey: string; }' to type 'Record<string, string>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue(138,27): error TS2352: Conversion of type '{ enabled: boolean; apiKey: string; } | { enabled: boolean; domain: string; cookie: string; } | { enabled: boolean; } | { enabled: boolean; apiKey: string; }' to type 'Record<string, string>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
src/features/settings/PdfSettings.vue(45,17): error TS2367: This comparison appears to be unintentional because the types '"vertical" | "horizontal" | "page" | "wrapped"' and '"continuous"' have no overlap.
|
||||
src/features/settings/PdfSettings.vue(47,32): error TS2322: Type '"continuous"' is not assignable to type '"vertical" | "horizontal" | "page" | "wrapped" | undefined'.
|
||||
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
|
||||
@@ -9,6 +9,17 @@ export default mergeConfig(
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
thresholds: {
|
||||
lines: 1,
|
||||
statements: 1,
|
||||
functions: 1,
|
||||
branches: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
+56
-6
@@ -2,24 +2,74 @@
|
||||
"name": "projectx",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"help": "node ./scripts/help.mjs",
|
||||
"guide": "node ./scripts/help.mjs",
|
||||
"setup": "bash ./scripts/setup.sh",
|
||||
"doctor": "bash ./scripts/doctor.sh",
|
||||
"dev": "concurrently --names \"server,client\" --prefix-colors \"blue,green\" \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:client": "cd client && npm run dev -- --host",
|
||||
"dev:server": "cd server && npm run start:dev",
|
||||
"quick": "pnpm run verify:fast",
|
||||
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
|
||||
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke",
|
||||
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke",
|
||||
"build:client": "pnpm --filter client build",
|
||||
"build:server": "pnpm --filter server build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test",
|
||||
"db:reset": "bash scripts/db/reset.sh",
|
||||
"db:seed": "bash scripts/postman/seed.sh"
|
||||
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client:baseline",
|
||||
"typecheck:full": "pnpm run typecheck:server && pnpm run typecheck:client",
|
||||
"typecheck:server": "pnpm --filter server type-check",
|
||||
"typecheck:client": "pnpm --filter client type-check",
|
||||
"typecheck:client:baseline": "node ./scripts/client-typecheck-baseline.mjs check",
|
||||
"typecheck:client:baseline:update": "node ./scripts/client-typecheck-baseline.mjs update",
|
||||
"lint": "pnpm run lint:check",
|
||||
"lint:check": "pnpm --filter server lint:check && pnpm --filter client lint:check",
|
||||
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
|
||||
"format": "pnpm --filter server format && pnpm --filter client format",
|
||||
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
|
||||
"test": "pnpm run test:server && pnpm run test:client",
|
||||
"test:server": "pnpm --filter server test",
|
||||
"test:client": "pnpm --filter client test:unit --run",
|
||||
"test:e2e:smoke": "pnpm --filter server test:e2e",
|
||||
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
|
||||
"coverage:server": "pnpm --filter server test:cov",
|
||||
"coverage:client": "pnpm --filter client test:cov --run",
|
||||
"db:up": "docker compose up -d --wait",
|
||||
"db:migrate": "pnpm --filter server db:migrate",
|
||||
"db:reset": "bash ./scripts/db/reset.sh",
|
||||
"db:seed": "bash ./scripts/db/seed.sh",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
"concurrently": "^9.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"prettier": "^3.6.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{md,json,yml,yaml}": [
|
||||
"pnpm exec prettier --write"
|
||||
],
|
||||
"client/src/**/*.{ts,vue}": [
|
||||
"pnpm --dir client exec eslint --fix",
|
||||
"pnpm exec prettier --write"
|
||||
],
|
||||
"server/{src,test}/**/*.ts": [
|
||||
"pnpm --dir server exec eslint --fix",
|
||||
"pnpm exec prettier --write"
|
||||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["@nestjs/core", "@parcel/watcher", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
|
||||
"onlyBuiltDependencies": [
|
||||
"@nestjs/core",
|
||||
"@parcel/watcher",
|
||||
"@swc/core",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const mode = process.argv[2] ?? 'check'
|
||||
const rootDir = process.cwd()
|
||||
const baselinePath = resolve(rootDir, 'client/typecheck-baseline.txt')
|
||||
|
||||
function runClientTypecheck() {
|
||||
const result = spawnSync('pnpm', ['--filter', 'client', 'exec', 'vue-tsc', '--build', '--pretty', 'false'], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
const diagnostics = parseDiagnostics(combinedOutput)
|
||||
return { result, combinedOutput, diagnostics }
|
||||
}
|
||||
|
||||
function parseDiagnostics(output) {
|
||||
const regex = /^(.*\.(?:ts|tsx|vue)\(\d+,\d+\): error TS\d+: .*)$/gm
|
||||
const rows = []
|
||||
|
||||
for (const match of output.matchAll(regex)) {
|
||||
const raw = String(match[1] ?? '').trim()
|
||||
rows.push(normalizeDiagnostic(raw))
|
||||
}
|
||||
|
||||
return [...new Set(rows)].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function normalizeDiagnostic(row) {
|
||||
const rootPrefix = `${rootDir.replaceAll('\\', '/')}/`
|
||||
let normalized = row.replaceAll('\\', '/')
|
||||
normalized = normalized.split(`${rootPrefix}client/`).join('')
|
||||
normalized = normalized.split(rootPrefix).join('')
|
||||
if (normalized.startsWith('client/')) normalized = normalized.slice('client/'.length)
|
||||
return normalized
|
||||
}
|
||||
|
||||
function readBaseline() {
|
||||
if (!existsSync(baselinePath)) return []
|
||||
return readFileSync(baselinePath, 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
}
|
||||
|
||||
function writeBaseline(diagnostics) {
|
||||
mkdirSync(dirname(baselinePath), { recursive: true })
|
||||
const header = [
|
||||
'# Client typecheck baseline',
|
||||
'# Auto-generated by: pnpm run typecheck:client:baseline:update',
|
||||
'# One diagnostic per line. Do not edit manually unless necessary.',
|
||||
'',
|
||||
]
|
||||
writeFileSync(baselinePath, `${header.join('\n')}${diagnostics.join('\n')}\n`, 'utf8')
|
||||
}
|
||||
|
||||
if (mode === 'update') {
|
||||
const { result, combinedOutput, diagnostics } = runClientTypecheck()
|
||||
if (result.status !== 0 && diagnostics.length === 0) {
|
||||
console.error('Client typecheck failed without parsable diagnostics output.')
|
||||
console.error(combinedOutput)
|
||||
process.exit(1)
|
||||
}
|
||||
writeBaseline(diagnostics)
|
||||
console.log(`Updated ${baselinePath} with ${diagnostics.length} diagnostics.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (mode !== 'check') {
|
||||
console.error(`Unknown mode "${mode}". Use "check" or "update".`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const { result, combinedOutput, diagnostics } = runClientTypecheck()
|
||||
|
||||
if (result.status === 0) {
|
||||
console.log('Client typecheck is clean.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (diagnostics.length === 0) {
|
||||
console.error('Client typecheck failed without parsable diagnostics output.')
|
||||
console.error(combinedOutput)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baseline = readBaseline()
|
||||
if (baseline.length === 0) {
|
||||
console.error(`No baseline found at ${baselinePath}.`)
|
||||
console.error('Run: pnpm run typecheck:client:baseline:update')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baselineSet = new Set(baseline)
|
||||
const newDiagnostics = diagnostics.filter((line) => !baselineSet.has(line))
|
||||
|
||||
if (newDiagnostics.length > 0) {
|
||||
console.error(`Found ${newDiagnostics.length} new client type errors not present in baseline:`)
|
||||
for (const line of newDiagnostics) {
|
||||
console.error(`- ${line}`)
|
||||
}
|
||||
console.error('If these are intentional and accepted, refresh baseline:')
|
||||
console.error('pnpm run typecheck:client:baseline:update')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const resolvedCount = baseline.filter((line) => !diagnostics.includes(line)).length
|
||||
console.log(`No new client type errors. Baseline size: ${baseline.length}. Current: ${diagnostics.length}. Resolved: ${resolvedCount}.`)
|
||||
if (resolvedCount > 0) {
|
||||
console.log('You can refresh baseline to capture improvements:')
|
||||
console.log('pnpm run typecheck:client:baseline:update')
|
||||
}
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-projectx}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-projectx}"
|
||||
|
||||
echo "Ensuring PostgreSQL is running..."
|
||||
docker compose up -d --wait postgres
|
||||
|
||||
echo "Resetting database schema..."
|
||||
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE SCHEMA IF NOT EXISTS drizzle;"
|
||||
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);"
|
||||
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM drizzle.__drizzle_migrations;"
|
||||
|
||||
echo "Re-applying migrations..."
|
||||
pnpm run db:migrate
|
||||
|
||||
echo "Re-seeding baseline data..."
|
||||
pnpm run db:seed
|
||||
|
||||
echo "Cleaning generated local covers..."
|
||||
rm -rf local/data/covers local/data/staging/covers
|
||||
|
||||
echo "Database reset complete."
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
pnpm --filter server db:seed
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "Checking toolchain..."
|
||||
node -v
|
||||
pnpm -v
|
||||
|
||||
echo "Checking environment files..."
|
||||
[[ -f server/.env.example ]] || { echo "Missing server/.env.example"; exit 1; }
|
||||
[[ -f server/.env ]] || { echo "Missing server/.env (run: pnpm setup)"; exit 1; }
|
||||
|
||||
echo "Checking PostgreSQL container..."
|
||||
docker compose ps postgres
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-projectx}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-projectx}"
|
||||
docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
|
||||
echo "Checking local scripts..."
|
||||
[[ -d scripts/db ]] || { echo "Missing scripts/db helpers"; exit 1; }
|
||||
[[ -f scripts/help.mjs ]] || { echo "Missing scripts/help.mjs"; exit 1; }
|
||||
|
||||
echo "Doctor check passed."
|
||||
@@ -0,0 +1,24 @@
|
||||
const lines = [
|
||||
'ProjectX local workflow',
|
||||
'',
|
||||
'Core commands',
|
||||
' pnpm setup # one-time bootstrap on a machine',
|
||||
' pnpm dev # run server + client for daily work',
|
||||
' pnpm verify # default local quality gate before push',
|
||||
'',
|
||||
'Optional',
|
||||
' pnpm quick # faster local confidence loop',
|
||||
' pnpm verify:strict # aspirational strict gate (format + full typecheck)',
|
||||
' pnpm guide # print this command reference',
|
||||
' pnpm typecheck:client:baseline:update # refresh accepted client TS baseline',
|
||||
' pnpm doctor # environment health checks',
|
||||
' pnpm coverage # coverage reports for server + client',
|
||||
'',
|
||||
'Database helpers',
|
||||
' pnpm db:up',
|
||||
' pnpm db:migrate',
|
||||
' pnpm db:reset',
|
||||
' pnpm db:seed',
|
||||
];
|
||||
|
||||
process.stdout.write(`${lines.join('\n')}\n`);
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [[ ! -f server/.env ]]; then
|
||||
cp server/.env.example server/.env
|
||||
echo "Created server/.env from server/.env.example"
|
||||
fi
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
echo "Starting PostgreSQL..."
|
||||
docker compose up -d --wait
|
||||
|
||||
echo "Applying migrations..."
|
||||
pnpm run db:migrate
|
||||
|
||||
echo "Seeding baseline data..."
|
||||
pnpm run db:seed
|
||||
|
||||
pnpm run doctor
|
||||
@@ -54,6 +54,16 @@ pnpm test:e2e # end-to-end tests
|
||||
pnpm test:cov # unit tests with coverage
|
||||
```
|
||||
|
||||
## Quality
|
||||
|
||||
```bash
|
||||
pnpm type-check # TypeScript checks (no emit)
|
||||
pnpm lint:check # Non-mutating lint checks
|
||||
pnpm lint:fix # Auto-fix lint issues
|
||||
pnpm format:check # Prettier check
|
||||
pnpm format # Prettier write
|
||||
```
|
||||
|
||||
## Key conventions
|
||||
|
||||
- Constructor injection with `@Inject(DB)` for the Drizzle instance.
|
||||
|
||||
@@ -28,7 +28,13 @@ export default tseslint.config(
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
},
|
||||
},
|
||||
|
||||
+23
-8
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "sh -c 'export $(grep -v \"^#\" .env | xargs) 2>/dev/null; exec nest start --watch'",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
@@ -15,14 +16,18 @@
|
||||
"db:generate": "drizzle-kit generate --name",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/scripts/db-seed.ts",
|
||||
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
|
||||
"sim:missing": "ts-node -r tsconfig-paths/register src/scripts/missing-detection-sim.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit -p tsconfig.build.json",
|
||||
"lint": "pnpm run lint:check",
|
||||
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"7z-wasm": "^1.2.0",
|
||||
@@ -38,11 +43,11 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.14",
|
||||
"@nestjs/platform-socket.io": "^11.1.14",
|
||||
"@nestjs/platform-fastify": "^11.1.16",
|
||||
"@nestjs/platform-socket.io": "^11.1.16",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/websockets": "^11.1.14",
|
||||
"@nestjs/websockets": "^11.1.16",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@projectx/types": "workspace:*",
|
||||
"@types/archiver": "^7.0.0",
|
||||
@@ -52,15 +57,17 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fast-xml-parser": "^5.4.1",
|
||||
"jose": "^6.1.3",
|
||||
"fast-xml-parser": "^5.4.2",
|
||||
"fastify": "^5.8.1",
|
||||
"jose": "^6.2.0",
|
||||
"ms": "^2.1.3",
|
||||
"nestjs-pino": "^4.6.0",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"nodemailer": "^8.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.19.0",
|
||||
"pg": "^8.20.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -133,6 +140,14 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"lines": 8,
|
||||
"statements": 8,
|
||||
"functions": 3,
|
||||
"branches": 5
|
||||
}
|
||||
},
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import type { StringValue } from 'ms';
|
||||
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
@@ -25,8 +26,8 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('auth.jwtSecret'),
|
||||
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn') },
|
||||
secret: config.getOrThrow<string>('auth.jwtSecret'),
|
||||
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
|
||||
}),
|
||||
}),
|
||||
UserModule,
|
||||
|
||||
@@ -204,10 +204,7 @@ export class BookRepository {
|
||||
metadataHash: koboSnapshotBooks.metadataHash,
|
||||
})
|
||||
.from(koboLibrarySnapshots)
|
||||
.leftJoin(
|
||||
koboSnapshotBooks,
|
||||
and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)),
|
||||
)
|
||||
.leftJoin(koboSnapshotBooks, and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)))
|
||||
.where(eq(koboLibrarySnapshots.userId, userId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
@@ -217,10 +214,7 @@ export class BookRepository {
|
||||
const rows = await this.db
|
||||
.select({ name: collections.name })
|
||||
.from(collectionBooks)
|
||||
.innerJoin(
|
||||
collections,
|
||||
and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId), eq(collections.syncToKobo, true)),
|
||||
)
|
||||
.innerJoin(collections, and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId), eq(collections.syncToKobo, true)))
|
||||
.where(eq(collectionBooks.bookId, bookId));
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
@@ -252,11 +246,7 @@ export class BookRepository {
|
||||
.where(
|
||||
and(
|
||||
inArray(books.libraryId, libraryIds),
|
||||
or(
|
||||
sql`${bookMetadata.title} ILIKE ${pattern}`,
|
||||
sql`${bookMetadata.seriesName} ILIKE ${pattern}`,
|
||||
isNotNull(matchedAuthors.bookId),
|
||||
),
|
||||
or(sql`${bookMetadata.title} ILIKE ${pattern}`, sql`${bookMetadata.seriesName} ILIKE ${pattern}`, isNotNull(matchedAuthors.bookId)),
|
||||
),
|
||||
)
|
||||
.orderBy(bookMetadata.title)
|
||||
|
||||
@@ -129,8 +129,15 @@ export class BookService {
|
||||
}
|
||||
|
||||
private sanitizeFilenameSegment(raw: string, fallback = 'download'): string {
|
||||
const fallbackSafe = fallback.replace(/[/\\:*?"<>|\0]/g, '_').trim().slice(0, 255) || 'download';
|
||||
const cleaned = raw.replace(/[/\\:*?"<>|\0]/g, '_').trim().slice(0, 255);
|
||||
const fallbackSafe =
|
||||
fallback
|
||||
.replace(/[/\\:*?"<>|\0]/g, '_')
|
||||
.trim()
|
||||
.slice(0, 255) || 'download';
|
||||
const cleaned = raw
|
||||
.replace(/[/\\:*?"<>|\0]/g, '_')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') return fallbackSafe;
|
||||
return cleaned;
|
||||
}
|
||||
@@ -207,9 +214,7 @@ export class BookService {
|
||||
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
|
||||
return this.sanitizeFilenameSegment(resolvedName ?? originalFilename, originalFilename);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Download filename pattern resolution failed for book ${file.bookId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
this.logger.warn(`Download filename pattern resolution failed for book ${file.bookId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return this.sanitizeFilenameSegment(originalFilename, originalFilename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, Req, Res } from '@nestjs/common';
|
||||
import type { MultipartFile } from '@fastify/multipart';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@@ -48,8 +48,7 @@ export class CoverService {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`Proxy failed: ${response.status}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MetadataModule } from '../metadata/metadata.module';
|
||||
@@ -19,8 +20,8 @@ import { ScannerService } from './scanner.service';
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('auth.jwtSecret'),
|
||||
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn') },
|
||||
secret: config.getOrThrow<string>('auth.jwtSecret'),
|
||||
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createReadStream } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { Readable } from 'stream';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { StagingMetadata } from '@projectx/types';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
@@ -138,7 +139,14 @@ export class StagingController {
|
||||
|
||||
@Post('files/bulk-edit')
|
||||
bulkEdit(@Body() dto: BulkEditStagingDto) {
|
||||
return this.service.bulkEdit(dto.fileIds, dto.selectAll, dto.excludedIds, dto.fields, dto.enabledFields, dto.mergeArrays);
|
||||
return this.service.bulkEdit(
|
||||
dto.fileIds,
|
||||
dto.selectAll,
|
||||
dto.excludedIds,
|
||||
dto.fields as Partial<StagingMetadata & Record<string, unknown>>,
|
||||
dto.enabledFields,
|
||||
dto.mergeArrays,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/preview-names')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
@@ -29,8 +30,8 @@ import { StagingRepository } from './staging.repository';
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('auth.jwtSecret'),
|
||||
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn') },
|
||||
secret: config.getOrThrow<string>('auth.jwtSecret'),
|
||||
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { dbConfig } from '../config/config';
|
||||
import { validateEnv } from '../config/env.validation';
|
||||
import { DbModule } from '../db/db.module';
|
||||
import { SeedModule } from '../modules/seed/seed.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [dbConfig],
|
||||
}),
|
||||
DbModule,
|
||||
SeedModule,
|
||||
],
|
||||
})
|
||||
class SeedCliModule {}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const app = await NestFactory.createApplicationContext(SeedCliModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
await app.close();
|
||||
}
|
||||
|
||||
void run();
|
||||
+162
-11
@@ -1,22 +1,173 @@
|
||||
import {
|
||||
Body,
|
||||
CanActivate,
|
||||
Controller,
|
||||
ExecutionContext,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Module,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { APP_GUARD, Reflector } from '@nestjs/core';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
import { IS_PUBLIC_KEY, Public } from '../src/common/decorators/public.decorator';
|
||||
import { RequirePermission } from '../src/common/decorators/require-permission.decorator';
|
||||
import { PermissionGuard } from '../src/common/guards/permission.guard';
|
||||
import { PermissionService } from '../src/common/services/permission.service';
|
||||
import type { RequestUser } from '../src/common/types/request-user';
|
||||
|
||||
beforeEach(async () => {
|
||||
type LoginBody = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const userRole = {
|
||||
id: 1,
|
||||
name: 'User',
|
||||
description: null,
|
||||
isSuperuser: false,
|
||||
isSystem: false,
|
||||
permissions: [] as Array<{ id: number; name: string }>,
|
||||
};
|
||||
|
||||
function makeUser(permissionNames: string[]): RequestUser {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'smoke-user',
|
||||
name: 'Smoke User',
|
||||
email: null,
|
||||
active: true,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
roles: [
|
||||
{
|
||||
...userRole,
|
||||
permissions: permissionNames.map((name, index) => ({ id: index + 1, name })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
class TestJwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined>; user?: RequestUser }>();
|
||||
const auth = request.headers.authorization;
|
||||
|
||||
if (auth === 'Bearer admin-token') {
|
||||
request.user = makeUser(['manage_users']);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (auth === 'Bearer user-token') {
|
||||
request.user = makeUser([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
class SmokeAuthController {
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
login(@Body() body: LoginBody) {
|
||||
if (body.username === 'admin' && body.password === 'admin') {
|
||||
return { accessToken: 'admin-token', user: makeUser(['manage_users']) };
|
||||
}
|
||||
return { accessToken: 'user-token', user: makeUser([]) };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('smoke')
|
||||
class SmokeProtectedController {
|
||||
@Get('private')
|
||||
getPrivate() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('admin')
|
||||
@RequirePermission('manage_users')
|
||||
getAdmin() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [SmokeAuthController, SmokeProtectedController],
|
||||
providers: [PermissionService, { provide: APP_GUARD, useClass: TestJwtAuthGuard }, { provide: APP_GUARD, useClass: PermissionGuard }],
|
||||
})
|
||||
class SmokeAppModule {}
|
||||
|
||||
describe('API smoke (e2e)', () => {
|
||||
let app: NestFastifyApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
imports: [SmokeAppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('POST /api/v1/auth/login returns an access token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { username: 'admin', password: 'admin' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accessToken).toBe('admin-token');
|
||||
});
|
||||
|
||||
it('GET /api/v1/smoke/private allows authenticated access', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/smoke/private',
|
||||
headers: { authorization: 'Bearer user-token' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('GET /api/v1/smoke/admin denies users without permission', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/smoke/admin',
|
||||
headers: { authorization: 'Bearer user-token' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/v1/smoke/admin allows users with required permission', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/smoke/admin',
|
||||
headers: { authorization: 'Bearer admin-token' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.(t|j)s$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"resolvePackageJsonExports": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@projectx/types$": "<rootDir>/../packages/types/src/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user