Repository: internetarchive/openlibrary

Issue:
Reading log `.csv` exports are malformed
<!-- What problem are we solving? What does the experience look like today? What are the symptoms? -->
The `title` and `authors` fields in our reading log `.csv` exports are not escaped.  If there are commas in either field, the CSV export will be malformed.

### Evidence / Screenshot (if possible)
In the following example, the author's name is in `{last_name}, {first_name}` format.
<details>
  <summary>Truncated CSV</summary>

```
Work ID,Title,Authors,First Publish Year,Edition ID,Edition Count,Bookshelf,My Ratings,Ratings Average,Ratings Count,Has Ebook,Subjects,Subject People,Subject Places,Subject Times
OL14866310W,The art of getting over,Powers, Stephen,1999,OL35495M,1,Already Read,,,,True,Graffiti | History,,United States,
```
</details>

### Relevant url?
<!-- `https://openlibrary.org/...` -->
All import and export options can be found here:
https://openlibrary.org/account/import

### Steps to Reproduce
<!-- What steps caused you to find the bug? -->
1. Find a book that has at least one comma in either the title or any author's name.  Add this book to your reading log.
2. Follow the relevant URL (above) and export your reading log.

<!-- What actually happened after these steps? What did you expect to happen? -->
* Actual:  Malformed reading log is downloaded.
* Expected: Reading log that conforms to CSV standards is downloaded.

### Details

- **Logged in (Y/N)?** Y
- **Browser type/version?** Any
- **Operating system?** Any
- **Environment (prod/dev/local)?** prod
<!-- If not sure, put prod -->

### Proposal & Constraints
<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->
In the `format_reading_log` function, ensure that any field that can contain commas is properly escaped.

### Related files
<!-- Files related to this issue; this is super useful for new contributors who might want to help! If you're not sure, leave this blank; a maintainer will add them. -->
[CSV definition](https://www.rfc-editor.org/rfc/rfc4180.html#section-2)

`format_reading_log` function:
https://github.com/internetarchive/openlibrary/blob/master/openlibrary/plugins/upstream/account.py#L895-L927

### Stakeholders
<!-- @ tag stakeholders of this bug -->



Discussion:

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
@@ -888,9 +888,18 @@ def generate_reading_log(self, username: str) -> str:
         bookshelf_map = {1: 'Want to Read', 2: 'Currently Reading', 3: 'Already Read'}
 
         def get_subjects(work: Work, subject_type: str) -> str:
-            return " | ".join(
-                s.title.replace(",", ";") for s in work.get_subject_links(subject_type)
-            )
+            return " | ".join(s.title for s in work.get_subject_links(subject_type))
+
+        def escape_csv_field(raw_string: str) -> str:
+            """
+            Formats given CVS field string such that it conforms to definition outlined
+            in RFC #4180.
+
+            Note: We should probably use
+            https://docs.python.org/3/library/csv.html
+            """
+            escaped_string = raw_string.replace('"', '""')
+            return f'"{escaped_string}"'
 
         def format_reading_log(book: dict) -> dict:
             """
@@ -907,8 +916,8 @@ def format_reading_log(book: dict) -> dict:
             ratings_average, ratings_count = ratings.values()
             return {
                 "work_id": work_key.split("/")[-1],
-                "title": work.title,
-                "authors": " | ".join(work.get_author_names()),
+                "title": escape_csv_field(work.title),
+                "authors": escape_csv_field(" | ".join(work.get_author_names())),
                 "first_publish_year": work.first_publish_year,
                 "edition_id": edition_id,
                 "edition_count": work.edition_count,
@@ -917,10 +926,18 @@ def format_reading_log(book: dict) -> dict:
                 "ratings_average": ratings_average,
                 "ratings_count": ratings_count,
                 "has_ebook": work.has_ebook(),
-                "subjects": get_subjects(work=work, subject_type="subject"),
-                "subject_people": get_subjects(work=work, subject_type="person"),
-                "subject_places": get_subjects(work=work, subject_type="place"),
-                "subject_times": get_subjects(work=work, subject_type="time"),
+                "subjects": escape_csv_field(
+                    get_subjects(work=work, subject_type="subject")
+                ),
+                "subject_people": escape_csv_field(
+                    get_subjects(work=work, subject_type="person")
+                ),
+                "subject_places": escape_csv_field(
+                    get_subjects(work=work, subject_type="place")
+                ),
+                "subject_times": escape_csv_field(
+                    get_subjects(work=work, subject_type="time")
+                ),
             }
 
         books = Bookshelves.iterate_users_logged_books(username)


Test patch: