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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Tests

on:
push:
branches: [develop, main]
branches: [main]
pull_request:
branches: [develop, main]
workflow_dispatch:
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ Open **http://localhost:8001** and create your account.
- **Point your phone at an ISBN barcode.** Real-time barcode scanning in the browser — no native app required.
- **Cover art from multiple sources.** Automatic search across AbeBooks, Open Library, Amazon, and Hardcover — plus manual upload or URL paste.
- **Full REST API.** OpenAPI-documented backend you can script against — build your own frontend, connect home automation, or pipe data into your own tools.
- **Third-party integrations.** Display your stats on [Dashy](https://dashy.to/), [Home Assistant](https://www.home-assistant.io/), or [Homepage](https://gethomepage.dev/) dashboards and more. [See all integrations →](https://docs.librislog.app/api/integrations/)
- **Lightweight.** Two Docker containers, one SQLite database.
- **Bilingual UI.** English and German with a localization framework ready for more languages.
- **Multi-language UI.** English, German, Spanish, French, and Chinese (Simplified) — with a localization framework ready for more languages.

---

Expand Down Expand Up @@ -127,10 +128,10 @@ MIT

## Star History

<a href="https://www.star-history.com/#codebude/librislog&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=codebude/librislog&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=codebude/librislog&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=codebude/librislog&type=Date" />
</picture>
<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" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=codebude/librislog&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=codebude/librislog&type=date&legend=top-left" />
</picture>
</a>
42 changes: 34 additions & 8 deletions backend/app/routers/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,27 +240,51 @@ def get_pages_per_day(
start_date_utc = start_date.astimezone(timezone.utc).replace(tzinfo=None)
end_date_utc = end_date.astimezone(timezone.utc).replace(tzinfo=None)

progress_entries = list(
# Books with at least one progress entry in the window — we need their full
# entry chains to compute correct daily averages.
book_ids_with_window_progress = set(
session.exec(
select(ReadingProgress)
select(ReadingProgress.book_id)
.where(
ReadingProgress.user_id == current_user.id,
ReadingProgress.created_at >= start_date_utc,
ReadingProgress.created_at <= end_date_utc,
)
.order_by(ReadingProgress.book_id, ReadingProgress.created_at)
.distinct()
).all()
)

# Load full entry chains for those books (including entries before the window
# so that the prev→curr delta and day_diff span are complete).
if book_ids_with_window_progress:
progress_entries = list(
session.exec(
select(ReadingProgress)
.where(
ReadingProgress.user_id == current_user.id,
ReadingProgress.book_id.in_(book_ids_with_window_progress),
)
.order_by(ReadingProgress.book_id, ReadingProgress.created_at)
).all()
)
else:
progress_entries = []

# All book_ids with *any* progress entry (used to exclude books from fallback).
all_book_ids_with_progress = set(
session.exec(
select(ReadingProgress.book_id)
.where(ReadingProgress.user_id == current_user.id)
.distinct()
).all()
)

books = list(
session.exec(select(Book).where(Book.user_id == current_user.id)).all()
)

books_with_progress = {e.book_id for e in progress_entries}

virtual_entries = []
for book in books:
if book.id not in books_with_progress or not book.date_started:
if book.id not in all_book_ids_with_progress or not book.date_started:
continue
# Finished books without date_finished have no bounded reading
# period; skip to avoid spreading pages from date_started to
Expand All @@ -281,11 +305,13 @@ def get_pages_per_day(
fallback_books = [
b
for b in books
if b.id not in books_with_progress
if b.id not in all_book_ids_with_progress
and b.reading_status == ReadingStatus.read
and b.date_started
and b.date_finished
and b.page_count
# Only include books whose reading period could overlap the window.
and _naive_utc(b.date_finished) >= start_date_utc
]
fallback_daily = _extract_book_level_daily_pages(fallback_books, tz, start_date_utc, end_date_utc)

Expand Down
27 changes: 0 additions & 27 deletions backend/tests/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,33 +315,6 @@ def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestCl
assert any("reading_status" in error for error in payload["errors"])


def test_data_import_execute_deletes_temp_file_after_completion(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
import_temp_dir = tmp_path / "import_temp"
monkeypatch.setattr(settings, "import_temp_dir", str(import_temp_dir))
csv_payload = "Title\nDune\n"
parse_resp = client.post(
"/api/data/import/parse",
files={"file": ("books.csv", csv_payload, "text/csv")},
)
assert parse_resp.status_code == 200
file_id = parse_resp.json()["file_id"]

temp_file = import_temp_dir / "1" / f"{file_id}.json"
assert temp_file.exists()

execute_resp = client.post(
"/api/data/import/execute",
json={
"file_id": file_id,
"mapping": {"title": {"source": "Title", "transform": None}},
"import_mode": "continue_on_error",
},
)
assert execute_resp.status_code == 200
events = _parse_sse(execute_resp.text)
assert any(event.get("event") == "complete" for event in events)
assert not temp_file.exists()


def test_data_import_execute_progress_uses_date_finished_for_read_books(
client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path
Expand Down
11 changes: 11 additions & 0 deletions docs/.vitepress/config.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default defineConfig({
{ text: 'CLI Reference', link: '/guide/cli' },
],
},
{ text: 'Integrations 🔗', link: '/api/integrations/' },
],
},
{
Expand All @@ -75,6 +76,16 @@ export default defineConfig({
items: [
{ text: 'Overview', link: '/api/' },
{ text: 'Headless Setup & API Keys', link: '/api/setup' },
{
text: 'Integrations',
link: '/api/integrations/',
collapsed: true,
items: [
{ text: 'Dashy', link: '/api/integrations/dashy' },
{ text: 'Home Assistant', link: '/api/integrations/homeassistant' },
{ text: 'Homepage', link: '/api/integrations/homepage' },
],
},
],
},
],
Expand Down
2 changes: 1 addition & 1 deletion docs/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ LibrisLog is a **multi-user book tracking web application** designed for readers
- **Cover Management**: Automatic cover image scraping from multiple sources with manual override
- **Data Portability**: Export/import library as JSON or CSV. Full backup and restore functionality
- **REST API**: Full API with OpenAPI documentation for programmatic access
- **Multilingual**: English and German UI support
- **Multilingual**: English, German, Spanish, French, and Chinese (Simplified) UI support
- **Themes**: Light, dark, and custom DaisyUI themes with persistent preferences

## Technology Stack
Expand Down
137 changes: 137 additions & 0 deletions docs/api/integrations/dashy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Dashy

LibrisLog can be integrated into [Dashy](https://dashy.to/), a self-hosted
dashboard for your services, using its
[HTML embedded widget](https://dashy.to/docs/widgets#html-embedded-widget).

This widget displays your reading statistics as styled stat cards directly on
your Dashy dashboard.

## Prerequisites

- A running LibrisLog instance reachable from your Dashy server
- An [API key](/api/integrations/#api-keys) with access to the
statistics endpoint
- **CORS must be configured** — add your Dashy URL to the
[`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable
of the LibrisLog backend so that the browser can fetch the API directly

## Configuration

Add the following to the Dashy `conf.yml` under the section or item where you
want the widget to appear:

```yaml
widgets:
- type: embed
updateInterval: 300
options:
html: |
<div class="librislog-widget">
<div class="ll-stat-item">
<span class="ll-label">Reading</span>
<span class="ll-value" id="ll-reading">-</span>
</div>
<div class="ll-stat-item">
<span class="ll-label">Read</span>
<span class="ll-value" id="ll-read">-</span>
</div>
<div class="ll-stat-item">
<span class="ll-label">Want to Read</span>
<span class="ll-value" id="ll-wtr">-</span>
</div>
<div class="ll-stat-item">
<span class="ll-label">Total Books</span>
<span class="ll-value" id="ll-total">-</span>
</div>
</div>
css: |
.librislog-widget {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding: 0.5rem;
font-family: inherit;
}
.ll-stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--background-elevated, rgba(255,255,255,0.05));
border: 1px solid var(--outline-color, rgba(255,255,255,0.1));
border-radius: 6px;
padding: 0.5rem;
text-align: center;
}
.ll-label {
font-size: 0.8rem;
opacity: 0.7;
color: var(--text-color, #fff);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ll-value {
font-size: 1.4rem;
font-weight: bold;
color: var(--primary, #00bc8c);
}
script: |
(async function() {
const apiUrl = '<LIBRISLOG-URL>/api/books/stats';
const apiKey = '<API-KEY>';

try {
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
}
});

if (!response.ok) throw new Error('API request failed');

const data = await response.json();

document.getElementById('ll-reading').innerText = data.books_currently_reading ?? data.books_reading ?? 0;
document.getElementById('ll-read').innerText = data.books_read ?? 0;
document.getElementById('ll-wtr').innerText = data.books_want_to_read ?? 0;
document.getElementById('ll-total').innerText = data.total_books ?? 0;

} catch (error) {
console.error('LibrisLog Widget Error:', error);
const elements = ['ll-reading', 'll-read', 'll-wtr', 'll-total'];
elements.forEach(id => {
const el = document.getElementById(id);
if (el) el.innerText = '!';
if (el) el.style.color = 'var(--danger, #ff0033)';
});
}
})();
```

Replace the placeholders with your own values:

| Placeholder | Example | Description |
|---|---|---|
| `<LIBRISLOG-URL>` | `http://192.168.1.100:8000` | The base URL of your LibrisLog instance (http or https) |
| `<API-KEY>` | `lk_nRHsF3jxIBDa9u....` | An API key with access to the statistics endpoint |

The `updateInterval` is specified in seconds. `300` equals 5 minutes.

## CORS

Since the widget runs inside the Dashy iframe and fetches the LibrisLog API
directly from the browser, you must add your Dashy URL to the
[`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable of
the LibrisLog backend. For example:

```
CORS_ORIGINS=["https://dashy.YOUR-DOMAIN"]
```

## Result

![Dashy Widget](/screenshots/integrations-dashy.png)
Loading