Skip to content
Open
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
89 changes: 89 additions & 0 deletions openlibrary/core/bookshelves.py
Original file line number Diff line number Diff line change
Expand Up @@ -800,3 +800,92 @@ def user_with_most_books(cls) -> list:
)
result = oldb.query(query)
return list(result)

@classmethod
def get_patrons_who_shelved_work(cls, work_id: str, limit: int = 3) -> list[dict]:
"""
Returns the last N patrons who added this work to their reading log,
along with their shelf status and 2 other books from their shelf.
Only patrons with public reading logs are included.

Args:
work_id: The numeric work ID (e.g., "123" for /works/OL123W)
limit: Maximum number of patrons to return

Returns:
List of dicts with keys: username, bookshelf_id, work_id, created, other_books
"""
oldb = db.get_db()

# Get recent patrons for this work
query = """
SELECT username, bookshelf_id, work_id, created
FROM bookshelves_books
WHERE work_id = $work_id
ORDER BY created DESC
LIMIT $fetch_limit
"""
results = list(
oldb.query(query, vars={'work_id': int(work_id), 'fetch_limit': limit * 3})
)

# Filter for public reading logs
public_patrons: list[dict] = []
for r in results:
pref = web.ctx.site.get(f'/people/{r.username}/preferences')
if (
pref
and pref.dict().get('notifications', {}).get('public_readlog') == 'yes'
):
patron = dict(r)
# Fetch 2 other books from this patron's shelf
other_query = """
SELECT work_id FROM bookshelves_books
WHERE username = $username AND work_id != $work_id
ORDER BY created DESC LIMIT 2
"""
other_results = list(
oldb.query(
other_query,
vars={'username': r.username, 'work_id': int(work_id)},
)
)
patron['other_books'] = [
{'work_id': row.work_id} for row in other_results
]
public_patrons.append(patron)
if len(public_patrons) >= limit:
break

return public_patrons

@classmethod
def get_covers_for_works(cls, work_ids: list[int]) -> dict[int, str | None]:
"""
Returns a mapping of work_id -> cover_url (Small size).

Args:
work_ids: List of numeric work IDs

Returns:
Dict mapping work_id to cover URL or None if no cover
"""
from openlibrary.plugins.worksearch.code import get_solr_works

if not work_ids:
return {}

keys = [f"/works/OL{wid}W" for wid in work_ids]
works = get_solr_works(set(keys), fields=['key', 'cover_i'])

covers: dict[int, str | None] = {}
for key, data in works.items():
# Extract numeric ID: "/works/OL123W" -> 123
wid = int(key.split('/')[-1][2:-1])
cover_id = data.get('cover_i')
if cover_id:
covers[wid] = f"https://covers.openlibrary.org/b/id/{cover_id}-S.jpg"
else:
covers[wid] = None

return covers
12 changes: 12 additions & 0 deletions openlibrary/i18n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -6419,6 +6419,18 @@ msgstr ""
msgid "by <a href=\"%s\">You</a>"
msgstr ""

#: lists/readinglog_patrons.html
msgid "Recently Added By"
msgstr ""

#: lists/readinglog_patrons.html
msgid "Book cover"
msgstr ""

#: lists/readinglog_patrons.html
msgid "Avatar"
msgstr ""

#: lists/showcase.html
#, python-format
msgid "My Lists (%(count)d)"
Expand Down
40 changes: 36 additions & 4 deletions openlibrary/plugins/openlibrary/partials.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,25 +297,57 @@ def __init__(self):
self.i = web.input(workId="", editionId="")

def generate(self) -> dict:
from openlibrary.core.bookshelves import Bookshelves

results: dict = {"partials": []}
work_key = self.i.workId
edition_key = self.i.editionId
keys = [k for k in (work_key, edition_key) if k]

# Do checks and render
# --- Custom Lists ---
lists = get_lists(keys)
results["hasLists"] = bool(lists)

if not lists:
results["partials"].append(_('This work does not appear on any lists.'))
else:
if lists:
query = "seed_count:[2 TO *] seed:(%s)" % " OR ".join(
f'"{k}"' for k in keys
)
all_url = "/search/lists?q=" + web.urlquote(query)
lists_template = render_template("lists/carousel", lists, all_url)
results["partials"].append(str(lists_template))

# --- Reading Log Patron Cards ---
patrons = []
if work_key:
# Extract numeric work ID: "/works/OL123W" -> "123"
work_id = work_key.split('/')[-1][2:-1]
patrons = Bookshelves.get_patrons_who_shelved_work(work_id, limit=3)

if patrons:
# Collect all work IDs to fetch covers
all_work_ids: set[int] = set()
for p in patrons:
all_work_ids.add(p['work_id'])
for other in p.get('other_books', []):
all_work_ids.add(other['work_id'])

covers = Bookshelves.get_covers_for_works(list(all_work_ids))

# Attach cover URLs to patron data
for p in patrons:
p['cover_url'] = covers.get(p['work_id'])
for other in p.get('other_books', []):
other['cover_url'] = covers.get(other['work_id'])

patrons_template = render_template(
"lists/readinglog_patrons", patrons, work_key
)
results["partials"].append(str(patrons_template))

# Show message only if BOTH are empty
if not lists and not patrons:
results["partials"].append(_('This work does not appear on any lists.'))

return results


Expand Down
55 changes: 55 additions & 0 deletions openlibrary/templates/lists/readinglog_patrons.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
$def with (patrons, work_key)

$code:
SHELF_NAMES = {1: "Want to Read", 2: "Currently Reading", 3: "Already Read"}

$if patrons:
<div class="readinglog-patrons">
<h3 class="readinglog-patrons__title">$_("Recently Added By")</h3>
<div class="readinglog-patrons__showcase">
$for p in patrons:
$ username = p["username"]
$ status = SHELF_NAMES.get(p["bookshelf_id"], "Shelved")

<div class="readinglog-card">
<!-- Header with status -->
<a class="readinglog-card__header" href="/people/$username/books">
<div class="readinglog-card__title">$status</div>
</a>

<!-- Covers section -->
<a class="readinglog-card__covers" href="/people/$username/books">
$# Current book cover
$if p.get('cover_url'):
<img src="$(p['cover_url'].replace('-S.jpg', '-M.jpg'))" alt="$_('Book cover')" loading="lazy"/>
$else:
<img src="/images/icons/avatar_book-sm.png" alt="$_('Book cover')" loading="lazy"/>
$# Other books
$for other in p.get('other_books', [])[:2]:
$if other.get('cover_url'):
<img src="$(other['cover_url'].replace('-S.jpg', '-M.jpg'))" alt="$_('Book cover')" loading="lazy"/>
$else:
<img src="/images/icons/avatar_book-sm.png" alt="$_('Book cover')" loading="lazy"/>
</a>

<!-- Bottom section -->
<div class="readinglog-card__bottom">
<div class="readinglog-card__user">
<a href="/people/$username">
<img src="/people/$username/avatar" alt="$_('Avatar')"/>
</a>
<div class="readinglog-card__username">
<a class="readinglog-card__username-link" href="/people/$username" title="$username">
@$username
</a>
</div>
</div>
$if ctx.user and ctx.user.key != "/people/" + username:
<div class="readinglog-card__follow-button">
$ is_subscribed = ctx.user.is_subscribed_user(username)
$:macros.Follow(username, following=is_subscribed, request_path="/people/" + username)
</div>
</div>
</div>
</div>
</div>
161 changes: 161 additions & 0 deletions static/css/components/readinglog-patrons.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
@import (less) "../less/colors.less";
@import (less) "../less/font-families.less";

.readinglog-patrons {
margin-top: 1.5rem;

&__title {
font-size: @font-size-title-medium;
font-weight: 600;
color: @dark-grey;
margin-bottom: 0.75rem;
}

&__showcase {
display: flex;
overflow-x: auto;
scrollbar-width: thin;
padding: 8px 0;
}
}

.readinglog-card {
img {
transition: scale 0.2s;
}

display: flex;
flex-direction: column;
margin-right: 24px;
flex-shrink: 0;

background-color: @beige;

@card-width: 215px;
width: @card-width;
height: 150px;

border: 1px solid fade(@black, 25%);
border-radius: 4px;
box-shadow: 2px 2px 4px fade(@black, 15%);

&__header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 7px 10px;
width: 100%;

a& {
text-decoration: none;
}
}

&__title {
font-weight: bold;
font-size: @font-size-label-large;
color: @black;
text-align: center;
}

&__user {
display: flex;
align-items: center;
font-size: @font-size-label-medium;
padding-left: 5px;
flex: 1;
min-width: 0;

img {
border-radius: 8px;
width: 30px;
height: 30px;
margin-right: 5px;
margin-top: -10px;
position: relative;
z-index: 3; // stylelint-disable-line scale-unlimited/declaration-strict-value
border: 2px solid @white;
flex-shrink: 0;
}
}

&__covers {
@cover-width: 64px;
@padding: 20px;

flex: 1;
min-height: 1px;
overflow: clip;
display: flex;
align-items: center;
padding: 0 @padding;

img {
width: @cover-width;
border-radius: 4px;
box-shadow: 4px 4px 0 fade(@black, 25%);
}

@overlap: ((3 * @cover-width - (@card-width - 2 * @padding)) / 2);

img:nth-child(1) {
z-index: 3; // stylelint-disable-line scale-unlimited/declaration-strict-value
transform: translate(0, 20px);
}

img:nth-child(2) {
z-index: 2; // stylelint-disable-line scale-unlimited/declaration-strict-value
transform: translate(-@overlap, 20px);
}

img:nth-child(3) {
z-index: 1; // stylelint-disable-line scale-unlimited/declaration-strict-value
transform: translate(-2 * @overlap, 20px);
}

&:hover img {
scale: 1.05;
}
}

&__bottom {
background: @grey-f4f4f4;
display: flex;
justify-content: space-between;
padding: 5px;
gap: 0.5em;
width: 100%;
border-radius: 0 0 4px 4px;
}

&__username {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

&__username-link {
a& {
text-decoration: none;
color: @dark-grey-two;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}

&__follow-button {
display: flex;
align-items: center;
padding-right: 5px;

.cta-btn {
font-size: @font-size-label-medium;
padding: 1px 10px;
border-radius: 4px;
box-shadow: none;
margin: 0;
}
}
}
Loading