diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c060393..2b9ae56 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -100,6 +100,10 @@ jobs: RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${VERSION}" printf '{"version":"%s","release_url":"%s"}\n' "$VERSION" "$RELEASE_URL" > ./dist/version.json + - name: Write CNAME + run: | + printf 'docs.librislog.app\n' > ./dist/CNAME + - uses: peaceiris/actions-gh-pages@v4 if: steps.check-release-docs.outputs.has_docs == 'true' with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb453d1..1c0f2b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -163,6 +163,7 @@ jobs: permissions: checks: write pull-requests: write + issues: write steps: - uses: actions/download-artifact@v8 with: @@ -218,7 +219,8 @@ jobs: fail-on-error: "false" - name: Post PR comment - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true uses: actions/github-script@v9 env: backend_passed: ${{ steps.backend.outputs.passed }} diff --git a/README.md b/README.md index 622d368..3844daa 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # LibrisLog

- ๐Ÿ“š Full Documentation + ๐Ÿ“š Full Documentation  ยท  - Quick Start + Quick Start  ยท  - API Reference + API Reference  ยท  - Nightly Docs + Nightly Docs

Tests Docker Build - Docs Build + Docs Build Python Svelte FastAPI @@ -91,7 +91,7 @@ Open **http://localhost:8001** and create your account. The backend is a standalone FastAPI application. The full API is documented via Swagger UI at `/api/docs` when the server is running. -Create API keys from the web UI (Profile โ†’ API Keys) for headless access. See the [API Reference](https://codebude.github.io/librislog/api/) for details. +Create API keys from the web UI (Profile โ†’ API Keys) for headless access. See the [API Reference](https://docs.librislog.app/api/) for details. ```bash cd backend @@ -117,7 +117,7 @@ uv run uvicorn app.main:app --reload ## Contributing -See the [Developer Setup](https://codebude.github.io/librislog/guide/developer-setup) guide for instructions on running LibrisLog locally, running tests, and using the CLI tool. +See the [Developer Setup](https://docs.librislog.app/guide/developer-setup) guide for instructions on running LibrisLog locally, running tests, and using the CLI tool. This project was developed with the assistance of AI coding tools under a human-supervised workflow. No AI-generated code is committed without human review and approval. diff --git a/backend/alembic/versions/f7e9d1c3b5a2_make_isbn_unique_per_user.py b/backend/alembic/versions/f7e9d1c3b5a2_make_isbn_unique_per_user.py new file mode 100644 index 0000000..bf853a6 --- /dev/null +++ b/backend/alembic/versions/f7e9d1c3b5a2_make_isbn_unique_per_user.py @@ -0,0 +1,69 @@ +"""make isbn unique per user instead of globally + +Revision ID: f7e9d1c3b5a2 +Revises: 86fa9b4f6d61 +Create Date: 2026-06-08 07:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "f7e9d1c3b5a2" +down_revision: Union[str, Sequence[str], None] = "86fa9b4f6d61" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _recreate_book_table(conn, *, global_unique: bool) -> None: + """Recreate the book table with either a global or per-user ISBN unique constraint.""" + old_cols = [] + for col in sa.inspect(conn).get_columns("book"): + old_cols.append(col) + + col_names = [c["name"] for c in old_cols] + pk_cols = [c["name"] for c in old_cols if c.get("primary_key")] + pk_name = pk_cols[0] if pk_cols else "id" + + col_defs = [] + for c in old_cols: + typ = str(c["type"]) + nullable = "NOT NULL" if not c.get("nullable", True) else "" + col_defs.append(f" {c['name']} {typ} {nullable}".strip()) + + extras = [f"PRIMARY KEY ({pk_name})"] + for fk in sa.inspect(conn).get_foreign_keys("book"): + extras.append( + f"FOREIGN KEY({fk['constrained_columns'][0]}) REFERENCES {fk['referred_table']}({fk['referred_columns'][0]})" + ) + extras.append("UNIQUE (isbn)" if global_unique else "UNIQUE (user_id, isbn)") + + col_list = ", ".join(col_names) + extra_sql = ",\n ".join(extras) + + sql = f"""CREATE TABLE book__new ( + {',\n '.join(col_defs)}, + {extra_sql} +)""" + conn.execute(sa.text(sql)) + conn.execute(sa.text(f"INSERT INTO book__new ({col_list}) SELECT {col_list} FROM book")) + + for idx in sa.inspect(conn).get_indexes("book"): + idx_name = idx["name"] + if idx_name: + cols = ", ".join(idx["column_names"]) + unique = "UNIQUE " if idx.get("unique") else "" + conn.execute(sa.text(f"CREATE {unique}INDEX IF NOT EXISTS {idx_name} ON book__new ({cols})")) + + conn.execute(sa.text("DROP TABLE book")) + conn.execute(sa.text("ALTER TABLE book__new RENAME TO book")) + + +def upgrade() -> None: + _recreate_book_table(op.get_bind(), global_unique=False) + + +def downgrade() -> None: + _recreate_book_table(op.get_bind(), global_unique=True) diff --git a/backend/app/models.py b/backend/app/models.py index c04369c..3ea2198 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -54,11 +54,15 @@ class UserRole(str, Enum): class Book(SQLModel, table=True): """A book in the user's library.""" + __table_args__ = ( + sa.UniqueConstraint("user_id", "isbn", name="uq_book_user_id_isbn"), + ) + id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(index=True) subtitle: Optional[str] = None author: str = Field(default="", index=True) - isbn: Optional[str] = Field(default=None, unique=True) + isbn: Optional[str] = Field(default=None) cover_url: Optional[str] = None publisher: Optional[str] = None diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index 5feead5..05f96f3 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -124,7 +124,7 @@ def _normalize_language(language: str | None) -> str | None: def _raise_integrity_conflict(exc: IntegrityError) -> None: """Convert ISBN unique-constraint violations to HTTP 409.""" message = str(exc.orig).lower() if exc.orig else str(exc).lower() - if "book.isbn" in message and "unique" in message: + if ("book.isbn" in message or "uq_book_user_id_isbn" in message) and "unique" in message: raise HTTPException(status_code=409, detail="This ISBN is already used by another book.") from exc raise diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index fe3a750..3a9fc93 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -27,7 +27,7 @@ def _raise_integrity_conflict(exc: IntegrityError) -> None: """Convert ISBN unique-constraint violations to HTTP 409.""" message = str(exc.orig).lower() if exc.orig else str(exc).lower() - if "book.isbn" in message and "unique" in message: + if ("book.isbn" in message or "uq_book_user_id_isbn" in message) and "unique" in message: raise HTTPException(status_code=409, detail="This ISBN is already used by another book.") from exc raise diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 4e75371..9aef8eb 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -836,7 +836,10 @@ async def execute_import( }, "language": {"source": "", "transform": None}, "tags": {"source": "Bookshelves", "transform": None}, - "notes": {"source": "My Review", "transform": None}, + "notes": { + "source": "My Review", + "transform": "value.replace('
', '\n') if value else None", + }, "blurb": {"source": "", "transform": None}, "rating": { "source": "My Rating", @@ -846,7 +849,7 @@ async def execute_import( "source": "Exclusive Shelf", "transform": ( "shelf_map = {'to-read': 'want_to_read', " - "'currently-reading': 'currently_reading', 'read': 'read'}\n" + "'currently-reading': 'currently_reading', 'read': 'read', 'did-not-finish': 'did_not_finish'}\n" "status = shelf_map.get(value.strip().lower(), 'want_to_read')\n" "return status" ), diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 776704a..06c1f47 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -991,6 +991,84 @@ def test_suggest_user_isolation(client: TestClient, create_user_with_key: Callab assert resp2.json()["suggestions"] == [] +# โ”€โ”€ user data isolation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_same_isbn_allowed_for_different_users(client: TestClient, create_user_with_key: Callable[..., tuple[User, str]]) -> None: + """ISBN uniqueness is per-user โ€” two users can each have the same ISBN.""" + + shared_isbn = "9780441013593" + + # Create book with a shared ISBN for User 1 (default admin) + book1 = _create_book(client, title="Dune", author="Frank Herbert", page_count=412, isbn=shared_isbn) + + # Create second user and their book with the same ISBN + user2, key2 = create_user_with_key(email="other@example.com") + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + resp = c2.post("/api/books", json={ + "title": "Dune", "author": "Frank Herbert", "page_count": 412, "isbn": shared_isbn, + }) + assert resp.status_code == 201 + book2 = resp.json() + + # โ”€โ”€ list โ”€โ”€ + list1 = client.get("/api/books").json() + assert len(list1["books"]) == 1 + assert list1["books"][0]["id"] == book1["id"] + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + list2 = c2.get("/api/books").json() + assert len(list2["books"]) == 1 + assert list2["books"][0]["id"] == book2["id"] + + # โ”€โ”€ get by id (own) โ”€โ”€ + assert client.get(f"/api/books/{book1['id']}").status_code == 200 + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + assert c2.get(f"/api/books/{book2['id']}").status_code == 200 + + # โ”€โ”€ get by id (other user) โ”€โ”€ + assert client.get(f"/api/books/{book2['id']}").status_code == 404 + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + assert c2.get(f"/api/books/{book1['id']}").status_code == 404 + + # โ”€โ”€ update (other user) โ”€โ”€ + assert client.patch(f"/api/books/{book2['id']}", json={"title": "Hacked"}).status_code == 404 + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + assert c2.patch(f"/api/books/{book1['id']}", json={"title": "Hacked"}).status_code == 404 + + # โ”€โ”€ delete (other user) โ”€โ”€ + assert client.delete(f"/api/books/{book2['id']}").status_code == 404 + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + assert c2.delete(f"/api/books/{book1['id']}").status_code == 404 + + # โ”€โ”€ stats isolation โ”€โ”€ + stats1 = client.get("/api/books/stats").json() + assert stats1["total_books"] == 1 + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + stats2 = c2.get("/api/books/stats").json() + assert stats2["total_books"] == 1 + + # โ”€โ”€ suggestions isolation โ”€โ”€ + resp1 = client.get("/api/books/suggestions/authors?q=Frank") + assert resp1.json()["suggestions"] == ["Frank Herbert"] + + with TestClient(client.app) as c2: + c2.headers.update({"X-API-Key": key2}) + resp2 = c2.get("/api/books/suggestions/authors?q=Frank") + assert resp2.json()["suggestions"] == ["Frank Herbert"] + + # โ”€โ”€ prevent removing date_finished for read books โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def test_update_book_rejects_clearing_date_finished_for_read(client: TestClient) -> None: diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index 2b4646f..82298fb 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -27,7 +27,7 @@ export default defineConfig({ }, head: [ ['link', { rel: 'icon', href: '/favicon.svg' }], - ['script', { defer: '', 'data-domain': 'codebude.github.io/librislog', src: 'https://plausible.code-bude.net/js/script.js' }], + ['script', { defer: '', 'data-domain': 'docs.librislog.app', src: 'https://plausible.code-bude.net/js/script.js' }], ], themeConfig: { logo: '/logo.png', diff --git a/docs/.vitepress/config.nightly.ts b/docs/.vitepress/config.nightly.ts index 1eb2dd4..672c4b5 100644 --- a/docs/.vitepress/config.nightly.ts +++ b/docs/.vitepress/config.nightly.ts @@ -3,17 +3,17 @@ import baseConfig from './config.base' export default defineConfig({ ...baseConfig, - base: '/librislog/next/', + base: '/next/', head: [ ...(baseConfig.head || []), - ['link', { rel: 'icon', href: '/librislog/next/favicon.svg', type: 'image/svg+xml' }], - ['link', { rel: 'alternate icon', href: '/librislog/next/favicon.ico', sizes: 'any' }], + ['link', { rel: 'icon', href: '/next/favicon.svg', type: 'image/svg+xml' }], + ['link', { rel: 'alternate icon', href: '/next/favicon.ico', sizes: 'any' }], ], themeConfig: { ...baseConfig.themeConfig, nav: [ ...(baseConfig.themeConfig?.nav || []), - { text: 'Release Docs', link: 'https://codebude.github.io/librislog/' }, + { text: 'Release Docs', link: 'https://docs.librislog.app/' }, ], }, }) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f16939d..e354033 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -3,17 +3,17 @@ import baseConfig from './config.base' export default defineConfig({ ...baseConfig, - base: '/librislog/', + base: '/', head: [ ...(baseConfig.head || []), - ['link', { rel: 'icon', href: '/librislog/favicon.svg', type: 'image/svg+xml' }], - ['link', { rel: 'alternate icon', href: '/librislog/favicon.ico', sizes: 'any' }], + ['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }], + ['link', { rel: 'alternate icon', href: '/favicon.ico', sizes: 'any' }], ], themeConfig: { ...baseConfig.themeConfig, nav: [ ...(baseConfig.themeConfig?.nav || []), - { text: 'Nightly Docs', link: 'https://codebude.github.io/librislog/next/' }, + { text: 'Nightly Docs', link: 'https://docs.librislog.app/next/' }, ], }, -}) \ No newline at end of file +}) diff --git a/docs/guide/developer-setup.md b/docs/guide/developer-setup.md index d2fc12e..9c54588 100644 --- a/docs/guide/developer-setup.md +++ b/docs/guide/developer-setup.md @@ -116,8 +116,8 @@ Output goes to `docs/.vitepress/dist/`. ### Nightly Docs The CI workflow publishes two doc sets on every push to `develop`: -- **Release docs** at `https://codebude.github.io/librislog/` โ€” built from the latest git tag -- **Nightly docs** at `https://codebude.github.io/librislog/next/` โ€” built from `develop` +- **Release docs** at `https://docs.librislog.app/` โ€” built from the latest git tag +- **Nightly docs** at `https://docs.librislog.app/next/` โ€” built from `develop` The nightly build uses a separate config (`config.nightly.ts`) which sets a different base path and swaps the nav link to point back to the release docs. diff --git a/frontend/src/lib/stores/updateCheck.test.ts b/frontend/src/lib/stores/updateCheck.test.ts index 97cf147..2d017d6 100644 --- a/frontend/src/lib/stores/updateCheck.test.ts +++ b/frontend/src/lib/stores/updateCheck.test.ts @@ -3,7 +3,7 @@ import { isNewer, checkForUpdate } from './updateCheck'; vi.mock('$lib/version', () => ({ version: 'v1.0.0', gitSha: 'abc1234' })); -const CHECK_URL = 'https://codebude.github.io/librislog/version.json'; +const CHECK_URL = 'https://docs.librislog.app/version.json'; const STORAGE_KEY = 'librislog_update_check'; function mockFetch(data: unknown, ok = true) { diff --git a/frontend/src/lib/stores/updateCheck.ts b/frontend/src/lib/stores/updateCheck.ts index a82217c..7a5d32d 100644 --- a/frontend/src/lib/stores/updateCheck.ts +++ b/frontend/src/lib/stores/updateCheck.ts @@ -7,7 +7,7 @@ export interface UpdateInfo { const STORAGE_KEY = 'librislog_update_check'; const CACHE_TTL = 60 * 60 * 1000; -const CHECK_URL = 'https://codebude.github.io/librislog/version.json'; +const CHECK_URL = 'https://docs.librislog.app/version.json'; function parseVersion(v: string): { parts: number[]; isPreRelease: boolean } { const segments = v.replace(/^v/, '').split(/[.-]/);