Repository: internetarchive/openlibrary

Issue:
API for books in reading log
An API to access read books in the reading log, would be great.
Currently, to access my read books through the API I need to add them to a list as well as marking them as read. Having the reading log accessible via API, like how the lists API works would be amazing.

### Summary of Requirements
- [ ] `/people/{username}/books/{shelfname}.json` : access a reading log as json.
    - [ ] Public reading logs should be accessible by anyone
    - [ ] Private reading logs should only be accessible by current user.
- [ ] Parameters:  `page=N` and `limit=N` to match our search.json endpoint

### Additional context
- Here's the file where the reading log logic lives; you'll likely want to make a copy of this class https://github.com/internetarchive/openlibrary/blob/98f6a69f9b29622ccf63aa24812975c2f57bec00/openlibrary/plugins/upstream/account.py#L733
- Here's an example of a .json endpoint @mekarpeles added recently you might want to use as your template: https://github.com/internetarchive/openlibrary/pull/4808/files


Discussion:
I came here to ask for this exact feature! I would love the json endpoints exposed for:

- https://openlibrary.org/people/username/books/currently-reading
- https://openlibrary.org/people/username/books/want-to-read
- https://openlibrary.org/people/username/books/already-read

P.S. it looks like the [Goodreads API is being deprecated](https://www.goodreads.com/topic/show/21788520-api-deprecation), so I would expect some more demand for these endpoints.

Thank you so much :)


Great idea! Updated description with a spec. This would be a great first issue :) 
I have a working proof of concept for this feature. Does anyone have an opinion about what data the API should return besides the basic key, title, author, etc.? Parallel the search.json results? The APIs that return book data are all over the place in terms of their result sets.
@brianmcdonald and @owensco — do you have preferences for what data is returned by this API? If I don't hear from anyone I'll just submit it with a basic set to meet my needs, and more fields can be added later.

What I have at this point looks like:

```
{
"page": 1, 
"reading_log_entries": [
   {
     "work": {
        "title": "The complete works of Mark Twain", 
        "key": "/works/OL53924W", 
        "author_keys": ["/authors/OL18319A"], 
        "first_publish_year": 1875
        }, 
     "logged_edition": "/books/OL7037695M", 
     "logged_date": "2021/05/14, 20:52:51"
  },
  {
     "work": { ... }
     "logged_edition": ..., 
     "logged_date": ...
  }
  ]
 }
```

Resolving patch:
diff --git a/openlibrary/plugins/upstream/account.py b/openlibrary/plugins/upstream/account.py
--- a/openlibrary/plugins/upstream/account.py
+++ b/openlibrary/plugins/upstream/account.py
@@ -12,6 +12,7 @@
 
 from infogami.infobase.client import ClientException
 from infogami.utils.context import context
+from infogami.utils.view import safeint
 import infogami.core.code as core
 
 from openlibrary import accounts
@@ -649,14 +650,10 @@ def GET(self):
         raise web.seeother(user.key + '/lists')
 
 
-
-
 class ReadingLog(object):
 
     """Manages the user's account page books (reading log, waitlists, loans)"""
 
-
-
     def __init__(self, user=None):
         self.user = user or accounts.get_current_user()
         #self.user.update_loan_status()
@@ -704,23 +701,31 @@ def get_waitlisted_editions(self):
             editions[i].waitlist_record = keyed_waitlists[editions[i].ocaid]
         return editions
 
+    def process_logged_books(self, logged_books):
+        work_ids = ['/works/OL%sW' % i['work_id'] for i in logged_books]
+        works = web.ctx.site.get_many(work_ids)
+        for i in range(len(works)):
+            # insert the logged edition (if present) and logged date
+            works[i].logged_date = logged_books[i]['created']
+            works[i].logged_edition = (
+                '/books/OL%sM' % logged_books[i]['edition_id']
+                if logged_books[i]['edition_id'] else '')
+        return works
+
     def get_want_to_read(self, page=1, limit=RESULTS_PER_PAGE):
-        work_ids = ['/works/OL%sW' % i['work_id'] for i in Bookshelves.get_users_logged_books(
+        return self.process_logged_books(Bookshelves.get_users_logged_books(
             self.user.get_username(), bookshelf_id=Bookshelves.PRESET_BOOKSHELVES['Want to Read'],
-            page=page, limit=limit)]
-        return web.ctx.site.get_many(work_ids)
+            page=page, limit=limit))
 
     def get_currently_reading(self, page=1, limit=RESULTS_PER_PAGE):
-        work_ids = ['/works/OL%sW' % i['work_id'] for i in Bookshelves.get_users_logged_books(
+        return self.process_logged_books(Bookshelves.get_users_logged_books(
             self.user.get_username(), bookshelf_id=Bookshelves.PRESET_BOOKSHELVES['Currently Reading'],
-            page=page, limit=limit)]
-        return web.ctx.site.get_many(work_ids)
+            page=page, limit=limit))
 
     def get_already_read(self, page=1, limit=RESULTS_PER_PAGE):
-        work_ids = ['/works/OL%sW' % i['work_id'] for i in Bookshelves.get_users_logged_books(
+        return self.process_logged_books(Bookshelves.get_users_logged_books(
             self.user.get_username(), bookshelf_id=Bookshelves.PRESET_BOOKSHELVES['Already Read'],
-            page=page, limit=limit)]
-        return web.ctx.site.get_many(work_ids)
+            page=page, limit=limit))
 
     def get_works(self, key, page=1, limit=RESULTS_PER_PAGE):
         """
@@ -733,12 +738,14 @@ def get_works(self, key, page=1, limit=RESULTS_PER_PAGE):
             #works = web.ctx.site.get_many([ ... ])
             raise
 
+
 class public_my_books(delegate.page):
     path = "/people/([^/]+)/books"
 
     def GET(self, username):
         raise web.seeother('/people/%s/books/want-to-read' % username)
 
+
 class public_my_books(delegate.page):
     path = "/people/([^/]+)/books/([a-zA-Z_-]+)"
 
@@ -769,6 +776,57 @@ def GET(self, username, key='loans'):
         raise web.seeother(user.key)
 
 
+class public_my_books_json(delegate.page):
+    encoding = "json"
+    path = "/people/([^/]+)/books/([a-zA-Z_-]+)"
+
+    def GET(self, username, key='want-to-read'):
+        i = web.input(page=1, limit=5000)
+        page = safeint(i.page, 1)
+        limit = safeint(i.limit, 5000)
+        """check if user's reading log is public"""
+        user = web.ctx.site.get('/people/%s' % username)
+        if not user:
+            return delegate.RawText(
+                json.dumps({'error': 'User %s not found' % username}),
+                content_type="application/json")
+        is_public = user.preferences().get('public_readlog', 'no') == 'yes'
+        logged_in_user = accounts.get_current_user()
+        if (is_public or
+                logged_in_user and logged_in_user.key.split('/')[-1] == username):
+            readlog = ReadingLog(user=user)
+            books = readlog.get_works(key, page, limit)
+            records_json = [
+                {
+                    'work':
+                    {
+                        'title': w.get('title'),
+                        'key': w.key,
+                        'author_keys': [a.author.key for a in w.get('authors', [])],
+                        'author_names': [a.author.name for a in w.get('authors', [])],
+                        'first_publish_year': w.first_publish_year or None,
+                        'lending_edition_s': (w._solr_data.get('lending_edition_s') or
+                                              None),
+                        'edition_key': w._solr_data.get('edition_key') or None,
+                        'cover_id': w._solr_data.get('cover_id') or None,
+                        'cover_edition_key': (w._solr_data.get('cover_edition_key') or
+                                              None),
+                    },
+                    'logged_edition': w.get('logged_edition') or None,
+                    'logged_date': (w.get('logged_date').strftime("%Y/%m/%d, %H:%M:%S")
+                                    if w.get('logged_date') else None),
+                } for w in books
+            ]
+            return delegate.RawText(json.dumps({
+                'page': page,
+                'reading_log_entries': records_json
+            }), content_type="application/json")
+        else:
+            return delegate.RawText(
+                json.dumps({'error': 'Shelf %s not found or not accessible' % key}),
+                content_type="application/json")
+
+
 class readinglog_stats(delegate.page):
     path = "/people/([^/]+)/books/([a-zA-Z_-]+)/stats"
 


Test patch: