Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ jobs:
permissions:
checks: write
pull-requests: write
issues: write
steps:
- uses: actions/download-artifact@v8
with:
Expand Down Expand Up @@ -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 }}
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# LibrisLog

<p align="center">
<a href="https://codebude.github.io/librislog/">📚 Full Documentation</a>
<a href="https://docs.librislog.app/">📚 Full Documentation</a>
&nbsp;·&nbsp;
<a href="https://codebude.github.io/librislog/guide/getting-started">Quick Start</a>
<a href="https://docs.librislog.app/guide/getting-started">Quick Start</a>
&nbsp;·&nbsp;
<a href="https://codebude.github.io/librislog/api/">API Reference</a>
<a href="https://docs.librislog.app/api/">API Reference</a>
&nbsp;·&nbsp;
<a href="https://codebude.github.io/librislog/next/">Nightly Docs</a>
<a href="https://docs.librislog.app/next/">Nightly Docs</a>
</p>

<p align="center">
<a href="https://github.com/codebude/librislog/actions/workflows/tests.yml"><img src="https://github.com/codebude/librislog/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
<a href="https://github.com/codebude?tab=packages&repo_name=librislog"><img src="https://github.com/codebude/librislog/actions/workflows/docker.yml/badge.svg" alt="Docker Build"></a>
<a href="https://codebude.github.io/librislog/"><img src="https://github.com/codebude/librislog/actions/workflows/docs.yml/badge.svg" alt="Docs Build"></a>
<a href="https://docs.librislog.app/"><img src="https://github.com/codebude/librislog/actions/workflows/docs.yml/badge.svg" alt="Docs Build"></a>
<img src="https://img.shields.io/badge/python-3.14-%233776AB?logo=python" alt="Python">
<img src="https://img.shields.io/badge/svelte-5-%23FF3E00?logo=svelte" alt="Svelte">
<img src="https://img.shields.io/badge/FastAPI-0.136-%23009688?logo=fastapi" alt="FastAPI">
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
69 changes: 69 additions & 0 deletions backend/alembic/versions/f7e9d1c3b5a2_make_isbn_unique_per_user.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion backend/app/routers/import_.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions backend/app/services/data_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<br/>', '\n') if value else None",
},
"blurb": {"source": "", "transform": None},
"rating": {
"source": "My Rating",
Expand All @@ -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"
),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/test_books.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/config.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions docs/.vitepress/config.nightly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
],
},
})
10 changes: 5 additions & 5 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
],
},
})
})
4 changes: 2 additions & 2 deletions docs/guide/developer-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/stores/updateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/stores/updateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/[.-]/);
Expand Down