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
-
+
@@ -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(/[.-]/);