Repository: internetarchive/openlibrary

Issue:
Allow the reading log to log editions instead of works
The Reading Log feature lets keep track of the books you’re currently reading, have finished reading, or want to read. Currently it only logs works, not editions.

![reading-log-screenshot](https://user-images.githubusercontent.com/7125952/70839674-1346bd80-1e0e-11ea-8f0e-ab7cf452a00b.png)


### Description of the solution I would like

I suggest changing this point to be able to log editions better than works (or editions and works alike).

### Stakeholders

I think this solution is more appealing to readers. Although often readers only share the title of the book they are reading (work), being an online reading log, I think it is more interesting to be able to share which particular book (edition) they are reading, have read, or want to read, not just the title of the original works. 


Discussion:
@dcapillae this seems to be more of a bug. It's a really good idea.
Thank you @dcapillae! Noted! :)
Versatility would be nice. When I want to read a work, I don't necessarily have a particular edition in mind, but when I am reading or have read then it is a specific edition. If it has to be either/or, I think logging an edition is preferred. I agree with @dcapillae that users would usually want to indicate the specific edition; especially if the original work is in a different language than the edition read.
@seabelis Absolutely. Wanted lists should accommodate a) specific edition; b) any edition; c) any edition in a specific language. 

Related, for sponsoring acquisitions: if we already have multiple copies in one language we shouldn't tell potential sponsors an unqualified "Not In Library" for other editions in that language.

Resolving patch:
diff --git a/openlibrary/core/bookshelves.py b/openlibrary/core/bookshelves.py
--- a/openlibrary/core/bookshelves.py
+++ b/openlibrary/core/bookshelves.py
@@ -1,4 +1,5 @@
 import logging
+
 import web
 from dataclasses import dataclass
 from datetime import date, datetime
@@ -7,7 +8,7 @@
 from openlibrary.plugins.worksearch.search import get_solr
 
 from openlibrary.utils.dateutil import DATE_ONE_MONTH_AGO, DATE_ONE_WEEK_AGO
-
+from infogami.infobase.utils import flatten
 from . import db
 
 logger = logging.getLogger(__name__)
@@ -202,6 +203,105 @@ def count_total_books_logged_by_user_per_shelf(
         result = oldb.query(query, vars=data)
         return {i['bookshelf_id']: i['count'] for i in result} if result else {}
 
+    # Iterates through a list of solr docs, and for all items with a 'logged edition'
+    # it will remove an item with the matching edition key from the list, and add it to
+    # doc["editions"]["docs"]
+    def link_editions_to_works(solr_docs):
+        """
+        :param solr_docs: Solr work/edition docs, augmented with reading log data
+        """
+        linked_docs: list[web.storage] = []
+        editions_to_work_doc = {}
+        # adds works to linked_docs, recording their edition key and index in docs_dict if present.
+        for doc in solr_docs:
+            if doc["key"].startswith("/works"):
+                linked_docs.append(doc)
+                if doc.get("logged_edition"):
+                    editions_to_work_doc.update({doc["logged_edition"]: doc})
+        # Attaches editions to the works, in second loop-- in case of misperformed order.
+        for edition in solr_docs:
+            if edition["key"].startswith("/books/"):
+                if work_doc := editions_to_work_doc.get(edition["key"]):
+                    work_doc.editions = [edition]
+                else:
+                    # raise error no matching work found
+                    logger.error("Error: No work found for edition %s" % edition["key"])
+        return linked_docs
+
+    @classmethod
+    def add_storage_items_for_redirects(
+        cls, reading_log_keys, solr_docs: list[web.Storage]
+    ) -> list[web.storage]:
+        """
+        Use reading_log_keys to fill in missing redirected items in the
+        the solr_docs query results.
+
+        Solr won't return matches for work keys that have been redirected. Because
+        we use Solr to build the lists of storage items that ultimately gets passed
+        to the templates, redirected items returned from the reading log DB will
+        'disappear' when not returned by Solr. This remedies that by filling in
+        dummy works, albeit with the correct work_id.
+        """
+
+        from openlibrary.plugins.worksearch.code import run_solr_query
+        from openlibrary.plugins.worksearch.schemes.works import WorkSearchScheme
+
+        fetched_keys = {doc["key"] for doc in solr_docs}
+        missing_keys = {work for (work, _) in reading_log_keys} - fetched_keys
+
+        """
+        Provides a proper 1-to-1 connection between work keys and edition keys; needed, in order to fill in the appropriate 'logged_edition' data
+        for the correctly pulled work later on, as well as to correctly update the post-redirect version back to the pre_redirect key.
+        Without this step, processes may error due to the 'post-redirect key' not actually existing within a user's reading log.
+        """
+        work_to_edition_keys = {
+            work: edition for (work, edition) in reading_log_keys if edition
+        }
+        edition_to_work_keys = {
+            edition: work for (work, edition) in reading_log_keys if edition
+        }
+
+        # Here, we add in dummied works for situations in which there is no edition key present, yet the work key accesses a redirect.
+        # Ideally, this will be rare, as there's no way to access the relevant information through Solr.
+        for key in missing_keys.copy():
+            if not work_to_edition_keys.get(key):
+                missing_keys.remove(key)
+                solr_docs.append(web.storage({"key": key}))
+
+        edition_keys_to_query = [
+            work_to_edition_keys[key].split("/")[2] for key in missing_keys
+        ]
+        fq = f'edition_key:({" OR ".join(edition_keys_to_query)})'
+        if not edition_keys_to_query:
+            return solr_docs
+        solr_resp = run_solr_query(
+            scheme=WorkSearchScheme(),
+            param={'q': '*:*'},
+            rows=len(edition_keys_to_query),
+            fields=list(
+                WorkSearchScheme.default_fetched_fields
+                | {'subject', 'person', 'place', 'time', 'edition_key'}
+            ),
+            facet=False,
+            extra_params=[("fq", fq)],
+        )
+
+        """
+        Now, we add the correct 'logged_edition' information to each document retrieved by the query, and substitute the work_key in
+        each doc for the original one.
+        """
+        for doc in solr_resp.docs:
+            for edition_key in doc["edition_key"]:
+                if pre_redirect_key := edition_to_work_keys.get(
+                    '/books/%s' % edition_key
+                ):
+                    doc["key"] = pre_redirect_key
+                    doc["logged_edition"] = work_to_edition_keys.get(pre_redirect_key)
+                    solr_docs.append(web.storage(doc))
+                    break
+
+        return solr_docs
+
     @classmethod
     def get_users_logged_books(
         cls,
@@ -230,6 +330,8 @@ def get_users_logged_books(
         from openlibrary.plugins.worksearch.code import run_solr_query
         from openlibrary.plugins.worksearch.schemes.works import WorkSearchScheme
 
+        # Sets the function to fetch editions as well, if not accessing the Want to Read shelf.
+        show_editions: bool = bookshelf_id != 1
         shelf_totals = cls.count_total_books_logged_by_user_per_shelf(username)
         oldb = db.get_db()
         page = int(page or 1)
@@ -248,36 +350,6 @@ class ReadingLogItem:
             logged_date: datetime
             edition_id: str
 
-        def add_storage_items_for_redirects(
-            reading_log_work_keys: list[str], solr_docs: list[web.Storage]
-        ) -> list[web.storage]:
-            """
-            Use reading_log_work_keys to fill in missing redirected items in the
-            the solr_docs query results.
-
-            Solr won't return matches for work keys that have been redirected. Because
-            we use Solr to build the lists of storage items that ultimately gets passed
-            to the templates, redirected items returned from the reading log DB will
-            'disappear' when not returned by Solr. This remedies that by filling in
-            dummy works, albeit with the correct work_id.
-            """
-            for idx, work_key in enumerate(reading_log_work_keys):
-                corresponding_solr_doc = next(
-                    (doc for doc in solr_docs if doc.key == work_key), None
-                )
-
-                if not corresponding_solr_doc:
-                    solr_docs.insert(
-                        idx,
-                        web.storage(
-                            {
-                                "key": work_key,
-                            }
-                        ),
-                    )
-
-            return solr_docs
-
         def add_reading_log_data(
             reading_log_books: list[web.storage], solr_docs: list[web.storage]
         ):
@@ -332,12 +404,19 @@ def get_filtered_reading_log_books(
             reading_log_books: list[web.storage] = list(
                 oldb.query(query, vars=query_params)
             )
+
             assert len(reading_log_books) <= filter_book_limit
 
-            # Wrap in quotes to avoid treating as regex. Only need this for fq
-            reading_log_work_keys = (
-                '"/works/OL%sW"' % i['work_id'] for i in reading_log_books
+            work_to_edition_keys = {
+                '/works/OL%sW' % i['work_id']: '/books/OL%sM' % i['edition_id']
+                for i in reading_log_books
+            }
+
+            # Separating out the filter query from the call allows us to cleanly edit it, if editions are required.
+            filter_query = 'key:(%s)' % " OR ".join(
+                '"%s"' % key for key in work_to_edition_keys
             )
+
             solr_resp = run_solr_query(
                 scheme=WorkSearchScheme(),
                 param={'q': q},
@@ -346,14 +425,27 @@ def get_filtered_reading_log_books(
                 facet=False,
                 # Putting these in fq allows them to avoid user-query processing, which
                 # can be (surprisingly) slow if we have ~20k OR clauses.
-                extra_params=[('fq', f'key:({" OR ".join(reading_log_work_keys)})')],
+                extra_params=[('fq', filter_query)],
             )
             total_results = solr_resp.num_found
+            solr_docs = solr_resp.docs
+            if show_editions:
+                edition_data = get_solr().get_many(
+                    [work_to_edition_keys[work["key"]] for work in solr_resp.docs],
+                    fields=WorkSearchScheme.default_fetched_fields
+                    | {'subject', 'person', 'place', 'time', 'edition_key'},
+                )
+
+                solr_docs.extend(edition_data)
 
             # Downstream many things expect a list of web.storage docs.
             solr_docs = [web.storage(doc) for doc in solr_resp.docs]
             solr_docs = add_reading_log_data(reading_log_books, solr_docs)
 
+            # This function is only necessary if edition data was fetched.
+            if show_editions:
+                solr_docs = cls.link_editions_to_works(solr_docs)
+
             return LoggedBooksData(
                 username=username,
                 q=q,
@@ -404,25 +496,34 @@ def get_sorted_reading_log_books(
                 oldb.query(query, vars=query_params)
             )
 
-            reading_log_work_keys = [
-                '/works/OL%sW' % i['work_id'] for i in reading_log_books
+            reading_log_keys = [
+                (
+                    ['/works/OL%sW' % i['work_id'], '/books/OL%sM' % i['edition_id']]
+                    if show_editions and i['edition_id']
+                    else ['/works/OL%sW' % i['work_id'], ""]
+                )
+                for i in reading_log_books
             ]
+
             solr_docs = get_solr().get_many(
-                reading_log_work_keys,
+                [key for key in flatten(reading_log_keys) if key],
                 fields=WorkSearchScheme.default_fetched_fields
                 | {'subject', 'person', 'place', 'time', 'edition_key'},
             )
-            solr_docs = add_storage_items_for_redirects(
-                reading_log_work_keys, solr_docs
-            )
-            assert len(solr_docs) == len(reading_log_work_keys), (
-                "solr_docs is missing an item/items from reading_log_work_keys; "
-                "see add_storage_items_for_redirects()"
-            )
 
+            solr_docs = cls.add_storage_items_for_redirects(reading_log_keys, solr_docs)
             total_results = shelf_totals.get(bookshelf_id, 0)
             solr_docs = add_reading_log_data(reading_log_books, solr_docs)
 
+            # Attaches returned editions to works.
+            if show_editions:
+                solr_docs = cls.link_editions_to_works(solr_docs)
+
+            assert len(solr_docs) == len(reading_log_keys), (
+                "solr_docs is missing an item/items from reading_log_keys; "
+                "see add_storage_items_for_redirects()"
+            )
+
             return LoggedBooksData(
                 username=username,
                 q=q,


Test patch: