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
13 changes: 13 additions & 0 deletions openlibrary/core/follows.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,16 @@ def most_followed(cls, limit=100):
vars={'limit': limit},
)
return top_publishers

@classmethod
def is_following(cls, username):
oldb = db.get_db()
query = """
SELECT EXISTS(
SELECT 1
FROM follows
WHERE subscriber=$subscriber
)
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SQL query uses a bare SELECT EXISTS which returns a boolean, but the result access pattern expects a dictionary with an 'exists' key. This may work in the database library being used, but it's worth verifying that result[0].get('exists', False) will work correctly with the query result format. Consider explicitly naming the column with AS for clarity.

Suggested change
)
) AS exists

Copilot uses AI. Check for mistakes.
"""
result = oldb.query(query, vars={'subscriber': username})
return result and result[0].get('exists', False)
Comment on lines +180 to +191
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing docstring for the is_following method. This public API method should document its purpose, parameters, and return value for maintainability and to help other developers understand what it does.

Copilot uses AI. Check for mistakes.
37 changes: 36 additions & 1 deletion openlibrary/core/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import json
import re
from collections import namedtuple
from collections.abc import Callable, Iterable
from datetime import date, datetime
from typing import Any, cast
from typing import Any, Literal, cast
from urllib.parse import urlsplit

import babel
Expand Down Expand Up @@ -142,8 +143,12 @@ def datestr(
now: datetime | None = None,
lang: str | None = None,
relative: bool = True,
format: Literal['compact', 'long'] = 'long',
) -> str:
"""Internationalized version of web.datestr."""
if format == 'compact' and relative:
return _datestr_compact(then, now)

lang = lang or web.ctx.lang
if relative:
if now is None:
Expand All @@ -156,6 +161,36 @@ def datestr(
return format_date(then, lang=lang)


TimeDeltaUnit = namedtuple(
'TimeDeltaUnit', ['long_name', 'short_name', 'seconds_per_unit']
)

TIME_DELTA_UNITS = (
TimeDeltaUnit('year', 'y', 3600 * 24 * 365),
TimeDeltaUnit('month', 'm', 3600 * 24 * 30),
TimeDeltaUnit('week', 'w', 3600 * 24 * 7),
TimeDeltaUnit('day', 'd', 3600 * 24),
TimeDeltaUnit('hour', 'h', 3600),
TimeDeltaUnit('minute', 'm', 60),
Comment on lines +166 to +174
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate short_name 'm' for both month and minute units will cause ambiguity in the output. Users won't be able to distinguish between months and minutes when viewing compact timestamps. Consider using unique abbreviations like 'mo' for month or 'min' for minute.

Copilot uses AI. Check for mistakes.
TimeDeltaUnit('second', 's', 1),
)


def _datestr_compact(then: datetime, now: datetime | None = None) -> str:
if now is None:
now = datetime.now()
delta = now - then

time_delta_unit = TIME_DELTA_UNITS[-1]
for tdu in TIME_DELTA_UNITS:
if delta.total_seconds() > tdu.seconds_per_unit:
time_delta_unit = tdu
break

result = f'{int(delta.total_seconds()) // time_delta_unit.seconds_per_unit}{time_delta_unit.short_name}'
Comment on lines +183 to +190
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop will always select the first unit where delta exceeds the unit's seconds, but it should select the largest appropriate unit. For example, a 2-day time delta would incorrectly be formatted as seconds/minutes/hours instead of days. The logic should iterate in reverse order or find the largest unit that fits, not the first one that the delta exceeds.

Suggested change
time_delta_unit = TIME_DELTA_UNITS[-1]
for tdu in TIME_DELTA_UNITS:
if delta.total_seconds() > tdu.seconds_per_unit:
time_delta_unit = tdu
break
result = f'{int(delta.total_seconds()) // time_delta_unit.seconds_per_unit}{time_delta_unit.short_name}'
total_seconds = int(delta.total_seconds())
time_delta_unit = TIME_DELTA_UNITS[-1]
for tdu in TIME_DELTA_UNITS:
if total_seconds >= tdu.seconds_per_unit:
time_delta_unit = tdu
break
result = f'{total_seconds // time_delta_unit.seconds_per_unit}{time_delta_unit.short_name}'

Copilot uses AI. Check for mistakes.
return result


def datetimestr_utc(then):
return then.strftime("%Y-%m-%dT%H:%M:%SZ")

Expand Down
51 changes: 39 additions & 12 deletions openlibrary/i18n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ msgid "Pending Imports"
msgstr ""

#: BookByline.html FulltextSearchSuggestionItem.html SearchResultsWork.html
#: account/notes.html account/observations.html
#: account/activity_feed.html account/notes.html account/observations.html
msgid "Unknown author"
msgstr ""

Expand Down Expand Up @@ -576,7 +576,7 @@ msgstr ""
msgid "Failed to fetch carousel."
msgstr ""

#: RawQueryCarousel.html
#: RawQueryCarousel.html account/mybooks.html
msgid "Retry?"
msgstr ""

Expand Down Expand Up @@ -1503,12 +1503,13 @@ msgstr ""
msgid "Notes"
msgstr ""

#: account/sidebar.html books/mybooks_breadcrumb_select.html
#: openlibrary/plugins/upstream/mybooks.py
#: account/mybooks.html account/sidebar.html
#: books/mybooks_breadcrumb_select.html openlibrary/plugins/upstream/mybooks.py
msgid "My Feed"
msgstr ""

#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html
#: account/activity_feed.html account/mybooks.html
#: account/readinglog_shelf_name.html account/sidebar.html
#: my_books/dropdown_content.html my_books/primary_action.html
#: openlibrary/plugins/upstream/mybooks.py search/sort_options.html
msgid "Already Read"
Expand Down Expand Up @@ -2300,12 +2301,14 @@ msgstr ""
msgid "Earliest trending data is from October 2017"
msgstr ""

#: account/mybooks.html account/sidebar.html my_books/dropdown_content.html
#: my_books/primary_action.html search/sort_options.html trending.html
#: account/activity_feed.html account/mybooks.html account/sidebar.html
#: my_books/dropdown_content.html my_books/primary_action.html
#: search/sort_options.html trending.html
msgid "Want to Read"
msgstr ""

#: account/mybooks.html account/readinglog_shelf_name.html account/sidebar.html
#: account/activity_feed.html account/mybooks.html
#: account/readinglog_shelf_name.html account/sidebar.html
#: my_books/dropdown_content.html my_books/primary_action.html
#: search/sort_options.html trending.html
msgid "Currently Reading"
Expand Down Expand Up @@ -2478,6 +2481,22 @@ msgid ""
"information form</a>."
msgstr ""

#: account/activity_feed.html lists/list_follow.html
msgid "Avatar of the owner of the list"
msgstr ""

#: account/activity_feed.html
msgid ""
"Here's the latest activity around the library. Follow other readers to "
"personalize your feed."
msgstr ""

#: account/activity_feed.html
msgid ""
"None of the people that you follow have logged books. When they do, "
"you'll see it here."
msgstr ""

#: account/create.html
msgid "Username may only contain numbers, letters, - or _"
msgstr ""
Expand Down Expand Up @@ -2754,6 +2773,14 @@ msgstr ""
msgid "My Loans"
msgstr ""

#: account/mybooks.html
msgid "Loading activity feed"
msgstr ""

#: account/mybooks.html
msgid "Failed to fetch activity feed."
msgstr ""

#: account/mybooks.html type/user/view.html
msgid "This reader has chosen to make their Reading Log private."
msgstr ""
Expand All @@ -2763,6 +2790,10 @@ msgstr ""
msgid "%(year_span)s reading goal"
msgstr ""

#: account/mybooks.html
msgid "Activity Feed"
msgstr ""

#: account/mybooks.html
msgid "View All Lists"
msgstr ""
Expand Down Expand Up @@ -6334,10 +6365,6 @@ msgstr ""
msgid "Cover of book"
msgstr ""

#: lists/list_follow.html
msgid "Avatar of the owner of the list"
msgstr ""

#: lists/list_follow.html lists/widget.html my_books/dropdown_content.html
msgid "You"
msgstr ""
Expand Down
52 changes: 52 additions & 0 deletions openlibrary/plugins/openlibrary/js/activity-feed/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { buildPartialsUrl } from '../utils'

/**
* Fetches and displays the activity feed for a "My Books" page.
*
* @param {HTMLElement} elem - Container for the activity feed
* @returns {Promise<void>}
*
* @see `/openlibrary/templates/account/activity_feed.html` for activity feed template
*/
export async function initActivityFeedRequest(elem) {
const fullPath = window.location.pathname
const splitPath = fullPath.split('/')
const username = splitPath[2] // Assumes an activity feed can only appear on the patron's "My Books" page
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number 2 used for extracting username from path. This assumes a specific URL structure ('/people/username/books') but is fragile if the path structure changes. Consider adding a comment explaining the expected path structure or using a more robust method like matching against a regex pattern.

Suggested change
const username = splitPath[2] // Assumes an activity feed can only appear on the patron's "My Books" page
// Expected path structure for a "My Books" activity feed page:
// "/people/{username}/books"
// After splitting on "/", this yields:
// splitPath[0] === "" (leading slash)
// splitPath[1] === "people"
// splitPath[2] === "{username}"
// splitPath[3] === "books"
const USERNAME_PATH_INDEX = 2
const username = splitPath[USERNAME_PATH_INDEX] // Assumes an activity feed can only appear on the patron's "My Books" page

Copilot uses AI. Check for mistakes.

const loadingIndicator = elem.querySelector('.loadingIndicator')
const retryElem = elem.querySelector('.activity-feed-retry')
const retryButton = retryElem.querySelector('.retry-btn')

function fetchPartialsAndUpdatePage() {
return fetch(buildPartialsUrl('ActivityFeed', {username: username}))
.then(resp => {
if (!resp.ok) {
throw Error('Failed to fetch partials')
}
return resp.json()
})
.then(data => {
const div = document.createElement('div')
div.innerHTML = data.partials.trim()
loadingIndicator.classList.add('hidden')
retryButton.classList.add('hidden')
for (const child of Array.from(div.children)) {
elem.insertAdjacentElement('beforeend', child)
}
})
.catch(() => {
// Show retry affordance
loadingIndicator.classList.add('hidden')
retryElem.classList.remove('hidden')
})
}

// Hydrate retry button
retryButton.addEventListener('click', async () => {
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing preventDefault() call in the click event handler. When the retry link is clicked, the default anchor behavior should be prevented to avoid page navigation to '#'.

Suggested change
retryButton.addEventListener('click', async () => {
retryButton.addEventListener('click', async (event) => {
event.preventDefault()

Copilot uses AI. Check for mistakes.
retryElem.classList.add('hidden')
loadingIndicator.classList.remove('hidden')
await fetchPartialsAndUpdatePage()
})

await fetchPartialsAndUpdatePage()
}
7 changes: 7 additions & 0 deletions openlibrary/plugins/openlibrary/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,11 @@ jQuery(function () {
import(/* webpackChunkName: "list-books" */ './list_books')
.then(module => module.ListBooks.init());
}

// Async-load activity feed
const activityFeedContainer = document.querySelector('.activity-feed-container')
if (activityFeedContainer) {
import(/* webpackChunkName: "activity-feed" */ './activity-feed')
.then(module => module.initActivityFeedRequest(activityFeedContainer))
}
});
17 changes: 17 additions & 0 deletions openlibrary/plugins/openlibrary/partials.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from openlibrary.core.lending import compose_ia_url, get_available
from openlibrary.i18n import gettext as _
from openlibrary.plugins.openlibrary.lists import get_lists, get_user_lists
from openlibrary.plugins.upstream.mybooks import ActivityFeed
from openlibrary.plugins.upstream.yearly_reading_goals import get_reading_goals
from openlibrary.plugins.worksearch.code import do_search, work_search
from openlibrary.plugins.worksearch.subjects import (
Expand Down Expand Up @@ -354,6 +355,21 @@ def generate(self) -> dict:
return {"partials": str(macro)}


class ActivityFeedPartial(PartialDataHandler):
"""Handler for "My Books" page activity feeds"""

def __init__(self):
self.i = web.input(username=None)

def generate(self) -> dict:
feed, follows_others = ActivityFeed.get_activity_feed(self.i.username)
feed_url = f'/people/{self.i.username}/books/feed'
template_result = render_template(
'account/activity_feed', feed, feed_url, follows_others
)
return {"partials": str(template_result)}


class PartialRequestResolver:
# Maps `_component` values to PartialDataHandler subclasses
component_mapping = { # noqa: RUF012
Expand All @@ -365,6 +381,7 @@ class PartialRequestResolver:
"LazyCarousel": LazyCarouselPartial,
"MyBooksDropperLists": MyBooksDropperListsPartial,
"ReadingGoalProgress": ReadingGoalProgressPartial,
"ActivityFeed": ActivityFeedPartial,
}

@staticmethod
Expand Down
Loading
Loading