Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4bc3fcf
Enhanced search features
codebude Aug 24, 2026
f988cdb
Merge pull request #66 from codebude/feature/extended-search
codebude Aug 24, 2026
ba311e1
Added support for multiple authors per book
codebude Aug 25, 2026
7882193
Clarify on author split modes in api and file import
codebude Aug 25, 2026
fd1c0dc
Mark author field as deprecated
codebude Aug 25, 2026
32e4b59
Joined validator for author and authors field to ensure at least one …
codebude Aug 25, 2026
26590e9
Added different authors statistics card
codebude Aug 25, 2026
e3e764d
Fix docs fastdom dependency
codebude Aug 25, 2026
bb99adc
Export tags and authors as list instead of flattened string in user d…
codebude Aug 25, 2026
c7253e5
Fix inverted top/worst rated book ordering on statistics page
codebude Aug 25, 2026
f4c4dd1
Allow import transforms to return lists for adaptive authors/tags tar…
codebude Aug 25, 2026
4100afc
Use authors as the import target field with backward-compatible mapping
codebude Aug 25, 2026
f7a6812
Show authors field with adaptive string/array hint in import mapping …
codebude Aug 25, 2026
46b774f
Document authors import target and CSV export separators
codebude Aug 25, 2026
0f230de
Fix data hygiene e2e seeding now that author is mandatory on create
codebude Aug 25, 2026
08e4bdb
Make CSV delimiter a user-choice on import
codebude Aug 25, 2026
8e40844
Option to set date_added on file imports
codebude Aug 25, 2026
c7bad5b
Merge pull request #67 from codebude/feature/multiple-authors-per-book
codebude Aug 25, 2026
6ce47af
Renamed availability to possession
codebude Aug 26, 2026
16834ed
New all books tab on library page
codebude Aug 26, 2026
411524e
Add release notes to docs
codebude Aug 26, 2026
e52736d
Merge pull request #68 from codebude/feature/multiple-minor-improvements
codebude Aug 26, 2026
2d25ad1
Add dashboard gamification (reading streaks & goals)
codebude Aug 26, 2026
44d26ad
Refresh reading streak after logging progress
codebude Aug 26, 2026
9863c9a
Add option to disable gamification on dashboard
codebude Aug 26, 2026
cbfea63
Documentation of reading streaks and goals functionality
codebude Aug 26, 2026
bd099c2
Make hero card on dashboard smaller (reduce height)
codebude Aug 26, 2026
cc5c30d
Reorder gamification card on dashboard
codebude Aug 26, 2026
a97fa63
Shuffle next-to-read suggestions on dashboard
codebude Aug 26, 2026
8216af2
Update release notes for v1.7.0
codebude Aug 26, 2026
9764d35
Merge pull request #69 from codebude/feature/gamification-features
codebude Aug 26, 2026
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
 · 
<a href="https://docs.librislog.app/api/">API Reference</a>
&nbsp;·&nbsp;
<a href="https://docs.librislog.app/releases">Release Notes</a>
&nbsp;·&nbsp;
<a href="https://docs.librislog.app/next/">Nightly Docs</a>
</p>

Expand Down Expand Up @@ -128,8 +130,6 @@ MIT

## Star History

## Star History

<a href="https://www.star-history.com/?repos=codebude%2Flibrislog&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=codebude/librislog&type=date&theme=dark&legend=top-left&sealed_token=63MQnBuCo06yuka4CK7jSufogWTsYJDymR_l4uRyMUp_LWPSC65IxbPKjm2UeEPNaU4GKsdNJG308hwGsMjSqtjKwc6Br0SiEkEx-UkS4-7OKgvFQfRqR8rB8bFTNi8eWhacZ2clPYvs_oKBPqUsbCDUXDqGQxuneV_1LAEC3AoIiqlsodcwv_RAvCvm" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""add reading goals to usersettings

Revision ID: 9a8b7c6d5e4f
Revises: f1b2c3d4e5a6
Create Date: 2026-08-26 13:15:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '9a8b7c6d5e4f'
down_revision: Union[str, Sequence[str], None] = 'f1b2c3d4e5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column('usersettings', sa.Column('goal_pages_per_day_enabled', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('usersettings', sa.Column('goal_pages_per_day', sa.Integer(), nullable=False, server_default='20'))
op.add_column('usersettings', sa.Column('goal_pages_per_month_enabled', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('usersettings', sa.Column('goal_pages_per_month', sa.Integer(), nullable=False, server_default='300'))
op.add_column('usersettings', sa.Column('goal_books_per_month_enabled', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('usersettings', sa.Column('goal_books_per_month', sa.Integer(), nullable=False, server_default='2'))
op.add_column('usersettings', sa.Column('goal_books_per_year_enabled', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('usersettings', sa.Column('goal_books_per_year', sa.Integer(), nullable=False, server_default='25'))


def downgrade() -> None:
op.drop_column('usersettings', 'goal_books_per_year')
op.drop_column('usersettings', 'goal_books_per_year_enabled')
op.drop_column('usersettings', 'goal_books_per_month')
op.drop_column('usersettings', 'goal_books_per_month_enabled')
op.drop_column('usersettings', 'goal_pages_per_month')
op.drop_column('usersettings', 'goal_pages_per_month_enabled')
op.drop_column('usersettings', 'goal_pages_per_day')
op.drop_column('usersettings', 'goal_pages_per_day_enabled')
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add gamification enabled to usersettings

Revision ID: a1b2c3d4e5f7
Revises: 9a8b7c6d5e4f
Create Date: 2026-08-26 15:20:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f7'
down_revision: Union[str, Sequence[str], None] = '9a8b7c6d5e4f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
'usersettings',
sa.Column('gamification_enabled', sa.Boolean(), nullable=False, server_default=sa.true())
)


def downgrade() -> None:
op.drop_column('usersettings', 'gamification_enabled')
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
)
"""
)
)
34 changes: 33 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 Expand Up @@ -160,6 +183,15 @@ class UserSettings(SQLModel, table=True):
timezone: str = Field(default="UTC", max_length=64)
theme: str = Field(default="light", max_length=20)
custom_theme: Optional[str] = Field(default=None, max_length=30)
goal_pages_per_day_enabled: bool = Field(default=False)
goal_pages_per_day: int = Field(default=20, ge=1)
goal_pages_per_month_enabled: bool = Field(default=False)
goal_pages_per_month: int = Field(default=300, ge=1)
goal_books_per_month_enabled: bool = Field(default=False)
goal_books_per_month: int = Field(default=2, ge=1)
goal_books_per_year_enabled: bool = Field(default=False)
goal_books_per_year: int = Field(default=25, ge=1)
gamification_enabled: bool = Field(default=True)


class ApiKey(SQLModel, table=True):
Expand Down
Loading
Loading