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
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""create author and book_author tables and backfill

Revision ID: c7a8d9e1f2a3
Revises: e2f3a4b5c6d7
Create Date: 2026-08-24 21:00:00
"""

import sqlalchemy as sa
from alembic import op


# revision identifiers, used by Alembic.
revision = "c7a8d9e1f2a3"
down_revision = "e2f3a4b5c6d7"
branch_labels = None
depends_on = None


def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)

if not inspector.has_table("author"):
op.create_table(
"author",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.UniqueConstraint("user_id", "name", name="uq_author_user_id_name"),
sa.PrimaryKeyConstraint("id"),
)

existing_author_indexes = {idx["name"] for idx in inspector.get_indexes("author")}
if "ix_author_user_id" not in existing_author_indexes:
op.create_index("ix_author_user_id", "author", ["user_id"], unique=False)
if "ix_author_name" not in existing_author_indexes:
op.create_index("ix_author_name", "author", ["name"], unique=False)

if not inspector.has_table("book_author"):
op.create_table(
"book_author",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["book_id"], ["book.id"]),
sa.ForeignKeyConstraint(["author_id"], ["author.id"]),
sa.PrimaryKeyConstraint("book_id", "author_id"),
)

existing_book_author_indexes = {idx["name"] for idx in inspector.get_indexes("book_author")}
if "ix_book_author_book_id" not in existing_book_author_indexes:
op.create_index("ix_book_author_book_id", "book_author", ["book_id"], unique=False)
if "ix_book_author_author_id" not in existing_book_author_indexes:
op.create_index("ix_book_author_author_id", "book_author", ["author_id"], unique=False)

# Backfill authors from the legacy book.author column. Each legacy value is
# treated as a single author name so names like "Asimov, Isaac" are preserved.
rows = bind.execute(
sa.text("SELECT id, user_id, author FROM book WHERE author IS NOT NULL AND author <> ''")
).fetchall()

for book_id, user_id, raw in rows:
name = " ".join(raw.strip().split())
if not name:
continue

author_id = bind.execute(
sa.text("SELECT id FROM author WHERE user_id = :user_id AND name = :name"),
{"user_id": user_id, "name": name},
).scalar()
if author_id is None:
author_id = bind.execute(
sa.text("INSERT INTO author (user_id, name) VALUES (:user_id, :name) RETURNING id"),
{"user_id": user_id, "name": name},
).scalar_one()

bind.execute(
sa.text(
"INSERT OR IGNORE INTO book_author (book_id, author_id) VALUES (:book_id, :author_id)"
),
{"book_id": book_id, "author_id": author_id},
)


def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)

if inspector.has_table("book_author"):
existing_book_author_indexes = {idx["name"] for idx in inspector.get_indexes("book_author")}
if "ix_book_author_author_id" in existing_book_author_indexes:
op.drop_index("ix_book_author_author_id", table_name="book_author")
if "ix_book_author_book_id" in existing_book_author_indexes:
op.drop_index("ix_book_author_book_id", table_name="book_author")
op.drop_table("book_author")

if inspector.has_table("author"):
existing_author_indexes = {idx["name"] for idx in inspector.get_indexes("author")}
if "ix_author_name" in existing_author_indexes:
op.drop_index("ix_author_name", table_name="author")
if "ix_author_user_id" in existing_author_indexes:
op.drop_index("ix_author_user_id", table_name="author")
op.drop_table("author")
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""drop author column from book

Revision ID: f1b2c3d4e5a6
Revises: c7a8d9e1f2a3
Create Date: 2026-08-24 21:10:00
"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "f1b2c3d4e5a6"
down_revision = "c7a8d9e1f2a3"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("book", schema=None) as batch_op:
batch_op.drop_column("author")


def downgrade() -> None:
with op.batch_alter_table("book", schema=None) as batch_op:
batch_op.add_column(sa.Column("author", sa.String(), nullable=True, server_default=""))

# Re-populate book.author from the relation tables before the author tables
# are dropped by the previous revision's downgrade.
bind = op.get_bind()
bind.execute(
sa.text(
"""
UPDATE book SET author = (
SELECT group_concat(a.name, ', ')
FROM book_author ba
JOIN author a ON ba.author_id = a.id
WHERE ba.book_id = book.id
)
"""
)
)
25 changes: 24 additions & 1 deletion backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ class Book(SQLModel, table=True):
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)
cover_url: Optional[str] = None
publisher: Optional[str] = None
Expand Down Expand Up @@ -128,6 +127,30 @@ class BookTag(SQLModel, table=True):
tag_id: int = Field(foreign_key="tag.id", primary_key=True, index=True)


class Author(SQLModel, table=True):
"""A user-specific author name that can be associated with books."""

__tablename__: str = "author"
__table_args__ = (sa.UniqueConstraint("user_id", "name", name="uq_author_user_id_name"),)

id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
name: str = Field(index=True)
created_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
)


class BookAuthor(SQLModel, table=True):
"""Many-to-many association between books and authors."""

__tablename__: str = "book_author"

book_id: int = Field(foreign_key="book.id", primary_key=True)
author_id: int = Field(foreign_key="author.id", primary_key=True, index=True)


class User(SQLModel, table=True):
"""A user account."""

Expand Down
52 changes: 45 additions & 7 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from app.auth import require_user
from app.config import settings
from app.database import get_session
from app.models import AcquisitionStatus, Book, BookTag, ReadingProgress, ReadingStatus, Tag, User
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, ReadingProgress, ReadingStatus, Tag, User
from app.schemas import (
BookCreate,
BookListResponse,
Expand All @@ -26,6 +26,13 @@
SuggestionList,
TagCloudEntry,
)
from app.services.authors import (
cleanup_orphan_authors,
join_authors,
load_authors_batch,
resolve_authors_payload,
sync_book_authors,
)
from app.services.cover_storage import (
delete_cover_file,
local_cover_filename,
Expand Down Expand Up @@ -130,11 +137,14 @@ def _raise_integrity_conflict(exc: IntegrityError) -> None:
raise


def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead:
"""Build a BookRead from a Book model with a pre-resolved tags string."""
def _build_book_read_with_tags(book: Book, tags_text: str | None, authors: list[str] | None = None) -> BookRead:
"""Build a BookRead from a Book model with pre-resolved tags and authors."""
payload = book.model_dump()
payload.pop("user_id", None)
payload["tags"] = tags_text
authors = authors or []
payload["authors"] = authors
payload["author"] = join_authors(authors)
return BookRead.model_validate(payload)


Expand Down Expand Up @@ -227,9 +237,10 @@ def list_books(
logger.debug("list_books — returning %d/%d book(s)", len(books), total)
book_ids = [b.id for b in books if b.id is not None]
book_tags_map = load_tags_batch(session, book_ids) if book_ids else {}
book_authors_map = load_authors_batch(session, book_ids) if book_ids else {}
return BookListResponse(
books=[
_build_book_read_with_tags(book, book_tags_map.get(book.id))
_build_book_read_with_tags(book, book_tags_map.get(book.id), book_authors_map.get(book.id))
for book in books
],
total=total,
Expand Down Expand Up @@ -348,10 +359,22 @@ def suggest_authors(
current_user: User = Depends(require_user),
session: Session = Depends(get_session),
) -> SuggestionList:
"""Autocomplete author names from the user's existing books."""
"""Autocomplete author names from the user's existing authors."""
assert current_user.id is not None
suggestions = _suggest_field(session, current_user.id, "author", q, limit)
return SuggestionList(suggestions=suggestions)
if not q.strip():
return SuggestionList(suggestions=[])
pattern = f"%{_escape_like(q)}%"
rows = session.exec(
select(Author.name)
.where(
Author.user_id == current_user.id,
col(Author.name).ilike(pattern, escape="\\"),
)
.distinct()
.order_by(Author.name)
.limit(limit)
).all()
return SuggestionList(suggestions=list(rows))


@router.get("/suggestions/publishers", response_model=SuggestionList)
Expand Down Expand Up @@ -420,6 +443,8 @@ async def create_book(
book_data["language"] = _normalize_language(book_data.get("language"))
book_data["cover_url"] = cover_url
book_data.pop("tags", None)
book_data.pop("author", None)
book_data.pop("authors", None)
book_data["user_id"] = current_user.id
_validate_dates(book_data)
book = Book.model_validate(book_data)
Expand All @@ -430,6 +455,8 @@ async def create_book(
session.rollback()
_raise_integrity_conflict(exc)
sync_book_tags(session, current_user.id, book.id or 0, book_in.tags)
names = resolve_authors_payload(book_in.author, book_in.authors) or []
sync_book_authors(session, current_user.id, book.id or 0, names)
try:
session.commit()
except IntegrityError as exc:
Expand Down Expand Up @@ -475,6 +502,10 @@ async def update_book(
update_data["language"] = _normalize_language(update_data.get("language"))
tags_provided = "tags" in update_data
tags_raw = update_data.pop("tags", None) if tags_provided else None
authors_payload = resolve_authors_payload(
update_data.pop("author", None), update_data.pop("authors", None)
)
authors_provided = authors_payload is not None
target_status = update_data.get("reading_status", book.reading_status)

# Download external cover URL -> local file.
Expand Down Expand Up @@ -520,6 +551,10 @@ async def update_book(
assert book.id is not None
sync_book_tags(session, current_user.id, book.id, tags_raw)
cleanup_orphan_tags(session, current_user.id)
if authors_provided:
assert book.id is not None
sync_book_authors(session, current_user.id, book.id, authors_payload)
cleanup_orphan_authors(session, current_user.id)
try:
session.commit()
except IntegrityError as exc:
Expand Down Expand Up @@ -676,11 +711,14 @@ def delete_book(

for link in session.exec(select(BookTag).where(BookTag.book_id == book.id)).all():
session.delete(link)
for link in session.exec(select(BookAuthor).where(BookAuthor.book_id == book.id)).all():
session.delete(link)
for entry in session.exec(
select(ReadingProgress).where(ReadingProgress.book_id == book.id)
).all():
session.delete(entry)
session.delete(book)
cleanup_orphan_tags(session, current_user.id)
cleanup_orphan_authors(session, current_user.id)
session.commit()
logger.info("Deleted book id=%s", book_id)
17 changes: 11 additions & 6 deletions backend/app/routers/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime
from typing import Any

from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, col, select
Expand Down Expand Up @@ -34,6 +34,7 @@
from app.services.data_import import (
BOOK_IMPORT_FIELDS,
PREDEFINED_MAPPINGS,
canonicalize_mapping,
compute_schema_fingerprint,
execute_import,
get_predefined_mapping,
Expand All @@ -54,7 +55,7 @@ def _mapping_read(model: ImportMapping) -> DataImportMappingRead:
id=model.id or 0,
name=model.name,
source_fields=json.loads(model.source_fields_json),
mapping={k: ImportFieldConfig(**v) for k, v in raw_mapping.items()},
mapping=canonicalize_mapping({k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}),
created_at=model.created_at,
updated_at=model.updated_at,
is_predefined=False,
Expand Down Expand Up @@ -87,9 +88,13 @@ def export_data(
@router.post("/import/parse", response_model=DataImportParseResponse)
async def parse_import_file(
file: UploadFile = File(...),
delimiter: str = Form(","),
current_user: User = Depends(require_user),
) -> DataImportParseResponse:
"""Parse an uploaded CSV or JSON import file and return field info and samples."""
"""Parse an uploaded CSV or JSON import file and return field info and samples.

``delimiter`` is the single-character CSV field separator (ignored for JSON).
"""
assert current_user.id is not None
allowed_content_types = {
"text/csv",
Expand All @@ -101,7 +106,7 @@ async def parse_import_file(
if file.content_type and file.content_type not in allowed_content_types:
raise HTTPException(status_code=415, detail="Unsupported upload content type. Use CSV or JSON files.")
try:
payload = parse_upload(await file.read(), file.filename or "upload", current_user.id)
payload = parse_upload(await file.read(), file.filename or "upload", current_user.id, delimiter)
except (ValueError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return DataImportParseResponse.model_validate(payload)
Expand Down Expand Up @@ -143,7 +148,7 @@ def save_import_mapping(
)
).first()

mapping_dict = {k: v.model_dump() for k, v in body.mapping.items()}
mapping_dict = {k: v.model_dump() for k, v in canonicalize_mapping(body.mapping).items()}
if existing:
existing.source_fields_json = json.dumps(body.source_fields)
existing.mapping_json = json.dumps(mapping_dict)
Expand Down Expand Up @@ -226,7 +231,7 @@ def get_import_mapping(
id=mapping_id,
name=str(pm.get("name", "")),
source_fields=list(raw_sources),
mapping={k: ImportFieldConfig(**v) for k, v in raw_mapping.items()},
mapping=canonicalize_mapping({k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}),
created_at=datetime(2000, 1, 1),
updated_at=datetime(2000, 1, 1),
is_predefined=True,
Expand Down
Loading
Loading