diff --git a/bundlesize.config.json b/bundlesize.config.json index 2224e73ff88..96f4cb84530 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -40,10 +40,6 @@ "path": "static/build/js/readinglog-stats.*.js", "maxSize": "137KB" }, - { - "path": "static/build/js/goodreads-import.*.js", - "maxSize": "2KB" - }, { "path": "static/build/js/admin.*.js", "maxSize": "1.3KB" diff --git a/openlibrary/i18n/messages.pot b/openlibrary/i18n/messages.pot index 8d1ee33f7b0..f68d365de66 100644 --- a/openlibrary/i18n/messages.pot +++ b/openlibrary/i18n/messages.pot @@ -146,11 +146,12 @@ msgstr "" msgid "Create new list" msgstr "" -#: CreateListModal.html EditButtons.html account/notifications.html -#: account/password/reset.html account/privacy.html admin/imports-add.html -#: admin/permissions.html books/add.html databarEdit.html interstitial.html -#: merge/authors.html my_books/dropdown_content.html type/list/edit.html -#: type/series/edit.html type/tag/form.html +#: CreateListModal.html EditButtons.html account/import.html +#: account/notifications.html account/password/reset.html account/privacy.html +#: admin/imports-add.html admin/permissions.html books/add.html +#: databarEdit.html interstitial.html merge/authors.html +#: my_books/dropdown_content.html type/list/edit.html type/series/edit.html +#: type/tag/form.html msgid "Cancel" msgstr "" @@ -2870,7 +2871,30 @@ msgid "What are star ratings?" msgstr "" #: account/import.html -msgid "Importing..." +msgid "Import Books" +msgstr "" + +#: account/import.html +msgid "Go to My Books" +msgstr "" + +#: account/import.html +msgid "Books Ready to Import" +msgstr "" + +#: account/import.html +msgid "Note:" +msgstr "" + +#: account/import.html +msgid "" +"Books from your 'Did Not Finish' shelf will be skipped during import as " +"we do not currently support it. We plan to add support for this in the " +"future." +msgstr "" + +#: account/import.html +msgid "Select all books ready to import" msgstr "" #: account/import.html @@ -2878,7 +2902,53 @@ msgid "Reason" msgstr "" #: account/import.html -msgid "No ISBN" +msgid "No valid books with ISBNs found to import." +msgstr "" + +#: account/import.html +msgid "Books Missing ISBNs" +msgstr "" + +#: account/import.html +msgid "" +"Unfortunately, these books do not have ISBN codes and currently we can't " +"import these." +msgstr "" + +#: account/import.html +msgid "Action" +msgstr "" + +#: account/import.html +msgid "Search OL" +msgstr "" + +#: account/import.html +msgid "Importing, please wait..." +msgstr "" + +#: account/import.html +msgid "Imported" +msgstr "" + +#: account/import.html +msgid "Unknown error" +msgstr "" + +#: account/import.html +msgid "Something went wrong. Try again." +msgstr "" + +#: account/import.html +msgid "Import 1 Book" +msgstr "" + +#: account/import.html +msgid "Import {count} Books" +msgstr "" + +#: account/import.html +msgid "Done - {succeeded} imported, {errors} failed" msgstr "" #: account/loan_history.html diff --git a/openlibrary/plugins/openlibrary/js/goodreads_import.js b/openlibrary/plugins/openlibrary/js/goodreads_import.js deleted file mode 100644 index 2a1fdfd46f3..00000000000 --- a/openlibrary/plugins/openlibrary/js/goodreads_import.js +++ /dev/null @@ -1,182 +0,0 @@ -import Promise from 'promise-polyfill'; - -export function initGoodreadsImport() { - - var count, prevPromise; - - $(document).on('click', 'th.toggle-all input', function () { - var checked = $(this).prop('checked'); - $('input.add-book').each(function () { - $(this).prop('checked', checked); - if (checked) { - $(this).attr('checked', 'checked'); - } - else { - $(this).removeAttr('checked'); - } - }); - const l = $('.add-book[checked*="checked"]').length; - $('.import-submit').attr('value', `Import ${l} Books`); - }); - - $(document).on('click', 'input.add-book', function () { - if ($(this).prop('checked')) { - $(this).attr('checked', 'checked'); - } - else { - $(this).removeAttr('checked'); - } - const l = $('.add-book[checked*="checked"]').length; - $('.import-submit').attr('value', `Import ${l} Books`); - }); - - //updates the progress bar based on the book count - function func1(value) { - const l = $('.add-book[checked*="checked"]').length; - const elem = document.getElementById('myBar'); - elem.style.width = `${value * (100 / l)}%`; - elem.innerHTML = `${value} Books`; - if (value * (100 / l) >= 100) { - elem.innerHTML = ''; - $('#myBar').append(' Go to your Reading Log '); - $('.cancel-button').addClass('hidden'); - } - } - - $('.import-submit').on('click', function () { - $('#myProgress').removeClass('hidden'); - $('.cancel-button').removeClass('hidden'); - $('input.import-submit').addClass('hidden'); - $('th.import-status').removeClass('hidden'); - $('th.status-reason').removeClass('hidden'); - const shelves = { read: 3, 'currently-reading': 2, 'to-read': 1 }; - count = 0; - prevPromise = Promise.resolve(); - $('input.add-book').each(function () { - var input = $(this), - checked = input.prop('checked'); - var value = JSON.parse(input.val().replace(/'/g, '"')); - var shelf = value['Exclusive Shelf']; - var shelf_id = 0; - const hasFailure = function () { - return $(`[isbn=${value['ISBN']}]`).hasClass('import-failure'); - }; - const fail = function (reason) { - if (!hasFailure()) { - const element = $(`[isbn=${value['ISBN']}]`); - element.append(`Error${reason}'`) - element.removeClass('selected'); - element.addClass('import-failure'); - } - }; - - if (!checked) { - func1(++count); - return; - } - - if (shelves[shelf]) { - shelf_id = shelves[shelf]; - } - - //used 'return' instead of 'return false' because the loop was being exited entirely - if (shelf_id === 0) { - fail('Custom shelves are not supported'); - func1(++count); - return; - } - - prevPromise = prevPromise.then(function () { // prevPromise changes in each iteration - $(`[isbn=${value['ISBN']}]`).addClass('selected'); - return getWork(value['ISBN']); // return a new Promise - }).then(function (data) { - var obj = JSON.parse(data); - $.ajax({ - url: `${obj['works'][0].key}/bookshelves.json`, - type: 'POST', - data: { - dont_remove: true, - edition_id: obj['key'], - bookshelf_id: shelf_id - }, - dataType: 'json' - }).fail(function () { - fail('Failed to add book to reading log'); - }).done(function () { - if (value['My Rating'] !== '0') { - return $.ajax({ - url: `${obj['works'][0].key}/ratings.json`, - type: 'POST', - data: { - rating: parseInt(value['My Rating']), - edition_id: obj['key'], - bookshelf_id: shelf_id - }, - dataType: 'json', - fail: function () { - fail('Failed to add rating'); - } - }); - } - }).then(function () { - if (value['Date Read'] !== '') { - const date_read = value['Date Read'].split('/'); // Format: "YYYY/MM/DD" - return $.ajax({ - url: `${obj['works'][0].key}/check-ins`, - type: 'POST', - data: JSON.stringify({ - edition_key: obj['key'], - event_type: 3, // BookshelfEvent.FINISH - year: parseInt(date_read[0]), - month: parseInt(date_read[1]), - day: parseInt(date_read[2]) - }), - dataType: 'json', - contentType: 'application/json', - beforeSend: function (xhr) { - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('Accept', 'application/json'); - }, - fail: function () { - fail('Failed to set the read date'); - } - }); - } - }); - if (!hasFailure()) { - $(`[isbn=${value['ISBN']}]`).append('Imported') - $(`[isbn=${value['ISBN']}]`).removeClass('selected'); - } - func1(++count); - }).catch(function () { - fail('Book not in collection'); - func1(++count); - }); - }); - - $('td.books-wo-isbn').each(function () { - $(this).removeClass('hidden'); - }); - }); - - function getWork(isbn) { - return new Promise(function (resolve, reject) { - var request = new XMLHttpRequest(); - - request.open('GET', `/isbn/${isbn}.json`); - request.onload = function () { - if (request.status === 200) { - resolve(request.response); // we get the data here, so resolve the Promise - } else { - reject(Error(request.statusText)); // if status is not 200 OK, reject. - } - }; - - request.onerror = function () { - reject(Error('Error fetching data.')); // error occurred, so reject the Promise - }; - - request.send(); // send the request - }); - } -} diff --git a/openlibrary/plugins/openlibrary/js/index.js b/openlibrary/plugins/openlibrary/js/index.js index 1a262393ba0..942617a807e 100644 --- a/openlibrary/plugins/openlibrary/js/index.js +++ b/openlibrary/plugins/openlibrary/js/index.js @@ -186,11 +186,6 @@ jQuery(function () { }); } - // conditionally loads Goodreads import based on class in the page - if (document.getElementsByClassName('import-table').length) { - import(/* webpackChunkName: "goodreads-import" */'./goodreads_import.js') - .then(module => module.initGoodreadsImport()); - } // conditionally load list seed item deletion dialog functionality based on id on lists pages if (document.getElementById('listResults')) { import(/* webpackChunkName: "ListViewBody" */'./lists/ListViewBody.js'); diff --git a/openlibrary/plugins/upstream/account.py b/openlibrary/plugins/upstream/account.py index 6a52e5de47b..f74bf7cf28e 100644 --- a/openlibrary/plugins/upstream/account.py +++ b/openlibrary/plugins/upstream/account.py @@ -858,7 +858,8 @@ def GET(self): @require_login def POST(self): - books, books_wo_isbns = process_goodreads_csv(web.input()) + input_csv = web.input(csv={}) + books, books_wo_isbns = process_goodreads_csv(input_csv) return render['account/import'](books, books_wo_isbns) @@ -1379,8 +1380,17 @@ def g(*a, **kw): def process_goodreads_csv(i): - - csv_payload = i.csv if isinstance(i.csv, str) else i.csv.decode() + if hasattr(i.csv, 'file'): + i.csv.file.seek(0) + raw_bytes = i.csv.file.read() + else: + raw_bytes = i.csv + + csv_payload = ( + raw_bytes + if isinstance(raw_bytes, str) + else raw_bytes.decode('utf-8', errors='replace') + ) csv_file = csv.reader(csv_payload.splitlines(), delimiter=',', quotechar='"') header = next(csv_file) books = {} diff --git a/openlibrary/plugins/upstream/code.py b/openlibrary/plugins/upstream/code.py index fd77cd8257a..1bdba95b290 100644 --- a/openlibrary/plugins/upstream/code.py +++ b/openlibrary/plugins/upstream/code.py @@ -29,6 +29,7 @@ borrow, # noqa: F401 side effects may be needed checkins, covers, + data_import, edits, merge_authors, models, @@ -386,6 +387,7 @@ def setup(): edits.setup() checkins.setup() yearly_reading_goals.setup() + data_import.setup() from openlibrary.plugins.upstream import data, jsdef diff --git a/openlibrary/plugins/upstream/data_import.py b/openlibrary/plugins/upstream/data_import.py new file mode 100644 index 00000000000..4f0a202905c --- /dev/null +++ b/openlibrary/plugins/upstream/data_import.py @@ -0,0 +1,340 @@ +import json +import logging +from collections import defaultdict + +import web + +from infogami.utils import delegate +from infogami.utils.view import require_login +from openlibrary import accounts +from openlibrary.core import db +from openlibrary.core.bookshelves import Bookshelves +from openlibrary.core.bookshelves_events import BookshelfEvent, BookshelvesEvents +from openlibrary.core.models import Edition, Ratings +from openlibrary.plugins.upstream.checkins import make_date_string +from openlibrary.utils import extract_numeric_id_from_olid + +logger = logging.getLogger("openlibrary.dataimporter") + +_DEFAULT_SHELVES = { + 'to_read': 1, + 'currently_reading': 2, + 'read': 3, +} + +_IGNORED_SHELVES = {'did_not_finish'} + + +def _normalize_shelf_name(name: str) -> str: + """Standardizes shelf names for consistent lookup and creation.""" + return name.strip().lower().translate(str.maketrans(' -', '__')) + + +def _get_edition_from_isbn(isbn, isbn_cache): + """Retrieves an Edition via ISBN/ASIN, using a memory cache to avoid redundant lookups.""" + if not isbn: + return None + + isbn_val, asin = Edition.get_isbn_or_asin(isbn) + if not Edition.is_valid_identifier(isbn_val, asin): + return None + + forms = Edition.get_identifier_forms(isbn_val, asin) + + for f in forms: + if f in isbn_cache: + return isbn_cache[f] + + edition = Edition.from_isbn(isbn) + if edition: + for f in forms: + isbn_cache[f] = edition + + return edition + + +def _prepare_context(user, username, oldb): + """Initializes the shared memory state and queues for batch database inserts.""" + books_already_in_default_bookshelves = oldb.query( + "SELECT work_id, bookshelf_id FROM bookshelves_books WHERE username=$username", + vars={'username': username}, + ) + + return { + "books_in_bookshelves": { # To check if book already in bookshelf + (str(e.work_id), int(e.bookshelf_id)) + for e in books_already_in_default_bookshelves + }, + "lists_map": { # list-name: list object + _normalize_shelf_name(lst.name): lst + for lst in user.get_lists(limit=None) + if getattr(lst, 'name', None) + }, + "isbn_cache": {}, + "works_in_list_cache": {}, + "lists_to_save": set(), # Tracks dirty lists + "pending_seeds": defaultdict( + list + ), # Queues new books to be added to custom lists + "pending_bookshelf_inserts": [], # Queues raw rows for the bookshelves_books table + "pending_dateread_events": [], # Queues raw rows for the bookshelves_events (dates read) table + } + + +def _process_this_books_shelves( + book, user, username, work_id, work_key, edition_id, ctx +): + """Maps Goodreads shelves to Open Library shelves/lists, and handles list/book insertions.""" + shelves = {_normalize_shelf_name(s) for s in book.get('shelves', [])} + + for norm_shelf in shelves: + # Ignore the did_not_finish shelf + if norm_shelf in _IGNORED_SHELVES: + continue + + # 1. Handle creation of completely new custom lists + if norm_shelf not in _DEFAULT_SHELVES and norm_shelf not in ctx['lists_map']: + new_list = user.new_list( + norm_shelf.replace('_', ' ').title(), + "Imported from Goodreads", + seeds=[], + ) + ctx['lists_map'][norm_shelf] = new_list + ctx['lists_to_save'].add(norm_shelf) + + # 2. Handle default Open Library shelves (Read, Currently Reading, Want to Read) + if norm_shelf in _DEFAULT_SHELVES: + shelf_id = _DEFAULT_SHELVES[norm_shelf] + + if (work_id, shelf_id) not in ctx['books_in_bookshelves']: + ctx['pending_bookshelf_inserts'].append( + { + 'username': username, + 'bookshelf_id': shelf_id, + 'work_id': work_id, + 'edition_id': edition_id, + } + ) + ctx['books_in_bookshelves'].add((work_id, shelf_id)) + + # 3. Handle adding books to custom lists + elif norm_shelf in ctx['lists_map']: + target_list = ctx['lists_map'][norm_shelf] + + # Lazy-load existing seeds for this list into memory on first access + if norm_shelf not in ctx['works_in_list_cache']: + ctx['works_in_list_cache'][norm_shelf] = { + s.key if hasattr(s, 'key') else s.get('key') + for s in (getattr(target_list, 'seeds', []) or []) + } + + if work_key in ctx['works_in_list_cache'][norm_shelf]: + continue + + ctx['pending_seeds'][norm_shelf].append({"key": work_key}) + ctx['lists_to_save'].add(norm_shelf) + ctx['works_in_list_cache'][norm_shelf].add(work_key) + + +def _process_this_books_rating(book, username, work_id, edition_id, ctx): + """ + Processes the 1-5 star user rating for a book. + Updates the context to prevent `_process_this_books_shelves` from writing + duplicate "Already Read" entries, as Ratings.add() enforces this inherently. + """ + raw_rating = book.get('rating') + + if not raw_rating or raw_rating == "0": + return + + try: + rating_val = int(raw_rating) + if 1 <= rating_val <= 5: + # Prevent double-insert conflict: Ratings.add() forces the book onto the 'Read' shelf. + # We add it to the context here so the shelf processor skips queueing it a second time. + ctx["books_in_bookshelves"].add((int(work_id), _DEFAULT_SHELVES['read'])) + + Ratings.add( + username=username, + work_id=work_id, + rating=rating_val, + edition_id=edition_id, + ) + except (ValueError, TypeError): + logger.warning( + f"Invalid rating value '{raw_rating}' for row {book.get('row_id', '')}" + ) + + +def _process_this_book_date_read(book, username, work_id, edition_id, ctx): + """Parses completion dates and queues the event insertion.""" + raw_date_read = book.get('date_read') + + if not raw_date_read: + return + + try: + parts = [ + int(p) + for p in str(raw_date_read).replace("/", "-").strip().split("-") + if p.strip() + ] + if not parts: + return + + year = parts[0] + month = parts[1] if len(parts) > 1 else None + day = parts[2] if len(parts) > 2 else None + + ctx["pending_dateread_events"].append( + { + 'username': username, + 'work_id': work_id, + 'edition_id': edition_id or BookshelvesEvents.NULL_EDITION_ID, + 'event_date': make_date_string(year, month, day), + 'event_type': BookshelfEvent.FINISH, + } + ) + except (ValueError, TypeError) as e: + logger.warning( + f"Failed to parse date_read '{raw_date_read}' for row {book.get('row_id', '')}: {e}" + ) + + +def _process_book(book, user, username, ctx): + """Master processor for resolving a book's identity and delegating its data processing.""" + if not isinstance(book, dict): + return { + "row_id": None, + "status": "error", + "reason": "Invalid book format payload. Expected a dictionary object.", + } + + row_id = book.get('row_id', '') + + isbn = str(book.get('isbn', '')).replace('="', '').replace('"', '').strip() + isbn13 = str(book.get('isbn13', '')).replace('="', '').replace('"', '').strip() + + if not isbn and not isbn13: + return {"row_id": row_id, "status": "error", "reason": "No valid ISBN provided"} + + try: + edition = _get_edition_from_isbn( + isbn13, ctx["isbn_cache"] + ) or _get_edition_from_isbn(isbn, ctx["isbn_cache"]) + if not edition or not getattr(edition, 'works', None): + return { + "row_id": row_id, + "status": "error", + "reason": "Book not found in Open Library", + } + + work_key = ( + edition.works[0]['key'] + if isinstance(edition.works[0], dict) + else getattr(edition.works[0], 'key', None) + ) + if not work_key: + return { + "row_id": row_id, + "status": "error", + "reason": "Missing Work mapping", + } + + work_id = extract_numeric_id_from_olid(work_key) + edition_id = extract_numeric_id_from_olid(edition.key) + + # IMPORTANT: + # Ratings must be processed first so it can register the "Already Read" side-effect + # in the context dictionary before the shelves processor runs. + _process_this_books_rating(book, username, work_id, edition_id, ctx) + _process_this_books_shelves( + book, user, username, work_id, work_key, edition_id, ctx + ) + _process_this_book_date_read(book, username, work_id, edition_id, ctx) + + return {"row_id": row_id, "status": "success"} + + except Exception as e: + logger.error(f"Error processing book with Row ID {row_id}: {e}", exc_info=True) + return {"row_id": row_id, "status": "error", "reason": "Internal server error"} + + +def _commit_changes(oldb, ctx): + """Executes all bulk inserts and saves custom list modifications.""" + if ctx["pending_bookshelf_inserts"]: + oldb.multiple_insert(Bookshelves.TABLENAME, ctx["pending_bookshelf_inserts"]) + + if ctx["pending_dateread_events"]: + oldb.multiple_insert( + BookshelvesEvents.TABLENAME, ctx["pending_dateread_events"] + ) + + for list_name in ctx["lists_to_save"]: + target_list = ctx["lists_map"][list_name] + + if list_name in ctx["pending_seeds"]: + target_list.seeds = ( + list(getattr(target_list, 'seeds', []) or []) + + ctx["pending_seeds"][list_name] + ) + + target_list._save(comment="Added books via Goodreads import") + + +class process_imports(delegate.page): + path = "/account/import/process/goodreads" + + @require_login + def POST(self): + raw = web.data() + + if not raw: + return delegate.RawText( + json.dumps({"error": "missing_body"}), + status="400 Bad Request", + content_type="application/json", + ) + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return delegate.RawText( + json.dumps({"error": "invalid_json"}), + status="400 Bad Request", + content_type="application/json", + ) + + books = data.get("books") + if not isinstance(books, list): + return delegate.RawText( + json.dumps({"error": "books_must_be_list"}), + status="400 Bad Request", + content_type="application/json", + ) + + try: + user = accounts.get_current_user() + username = user.get_username() + oldb = db.get_db() + + ctx = _prepare_context(user, username, oldb) + + results = [_process_book(book, user, username, ctx) for book in books] + + _commit_changes(oldb, ctx) + + return delegate.RawText( + json.dumps({"results": results}), content_type="application/json" + ) + + except Exception as e: + logger.error(f"Error in process_imports: {e}", exc_info=True) + raise web.HTTPError( + "500 Internal Server Error", + headers={"Content-Type": "application/json"}, + ) + + +def setup(): + pass diff --git a/openlibrary/plugins/upstream/tests/test_account.py b/openlibrary/plugins/upstream/tests/test_account.py index a8a2174e9b2..8bb2e89e9c0 100644 --- a/openlibrary/plugins/upstream/tests/test_account.py +++ b/openlibrary/plugins/upstream/tests/test_account.py @@ -1,7 +1,5 @@ import os -import sys -import pytest import web from .. import account @@ -143,15 +141,7 @@ def setup_method(self, method): } } - @pytest.mark.skipif(sys.version_info < (3, 0), reason="Python2's csv module doesn't support Unicode") def test_process_goodreads_csv_with_utf8(self): books, books_wo_isbns = account.process_goodreads_csv(web.storage({"csv": self.csv_data.decode("utf-8")})) assert books == self.expected_books assert books_wo_isbns == self.expected_books_wo_isbns - - def test_process_goodreads_csv_with_bytes(self): - # Note: In Python2, reading data as bytes returns a string, which should - # also be supported by account.process_goodreads_csv() - books, books_wo_isbns = account.process_goodreads_csv(web.storage({"csv": self.csv_data})) - assert books == self.expected_books - assert books_wo_isbns == self.expected_books_wo_isbns diff --git a/openlibrary/templates/account/import.html b/openlibrary/templates/account/import.html index 34607ff1160..2e6a864c620 100644 --- a/openlibrary/templates/account/import.html +++ b/openlibrary/templates/account/import.html @@ -1,137 +1,520 @@ $def with (books=None, books_wo_isbns=None) -
- - $if not books: +
+ $if not books and not books_wo_isbns:
+

$_("Import from Goodreads")

$:_('For instructions on exporting data refer to: Goodreads Import/Export')

+ enctype="multipart/form-data" + class="olform olform--decoration mt-2"> +
- -
$_("File size limit 10MB and .csv file type only") + +
+ + $_("File size limit 10MB and .csv file type only") +
-
+ +
+ type="submit" + value="$_('Load Books')" + data-ol-link-track="PatronImports|Goodreads">
+ +
+

$_("Export your Reading Log")

-

$_("Download a copy of your reading log.") $_("What is a reading log?")

+

+ $_("Download a copy of your reading log.") + + $_("What is a reading log?") + +

+ enctype="multipart/form-data" + class="olform olform--decoration patron-export-form mt-2"> +
- +
+

$_("Export your book notes")

-

$_("Download a copy of your book notes.") $_("What are book notes?")

+

+ $_("Download a copy of your book notes.") + + $_("What are book notes?") + +

+ enctype="multipart/form-data" + class="olform olform--decoration patron-export-form mt-2"> +
- +
+

$_("Export your reviews")

-

$_("Download a copy of your review tags.") $_("What are review tags?")

+

+ $_("Download a copy of your review tags.") + + $_("What are review tags?") + +

+ enctype="multipart/form-data" + class="olform olform--decoration patron-export-form mt-2"> +
- +
+

$_("Export your list overview")

-

$_("Download a summary of your lists and their contents.") $_("What are lists?")

+

+ $_("Download a summary of your lists and their contents.") + + $_("What are lists?") + +

+ enctype="multipart/form-data" + class="olform olform--decoration patron-export-form mt-2"> +
- +
+

$_("Export your star ratings")

-

$_("Download a copy of your star ratings.") $_("What are star ratings?")

+

+ $_("Download a copy of your star ratings.") + + $_("What are star ratings?") + +

+ enctype="multipart/form-data" + class="olform olform--decoration patron-export-form mt-2"> +
- +
+
$else: -
- -
- -
-
+
+ $ keys = ['ISBN', 'ISBN13', 'Title', 'My Rating', 'Shelves', 'Date Read'] + + $if books: +
+ +
+
+ + + + + +
+
+ + +
+

$_("Books Ready to Import")

+ +

+ $_("Note:") + $_("Books from your 'Did Not Finish' shelf will be skipped during import as we do not currently support it. We plan to add support for this in the future.") +

+ +
+ + + + + + $for key in keys: + + + + + + + + + $for isbn in books: + $ shelves_raw = books[isbn].get('Bookshelves', '') + $ parsed_shelves = [s.strip() for s in str(shelves_raw).split(",") if s.strip()] + $ exclusive_shelf = str(books[isbn].get('Exclusive Shelf', '')).strip() + $ exclusive_shelf and exclusive_shelf not in parsed_shelves and parsed_shelves.append(exclusive_shelf) + $ joined_shelves = ", ".join(parsed_shelves) + $ clean_isbn = str(books[isbn].get("ISBN")) + $ clean_isbn13 = str(books[isbn].get("ISBN13")) + $ rating = str(books[isbn].get('My Rating', '')) + $ date_read = str(books[isbn].get('Date Read', '')) + + + + + $for key in keys: + + + + + + +
+ + $key
+ + + $if key == 'My Rating': + $('-' if str(books[isbn].get('My Rating', '')) == '0' else books[isbn].get('My Rating', '')) + $elif key == 'Shelves': + $joined_shelves + $elif key in ('ISBN', 'ISBN13'): + $(str(books[isbn].get(key, '')).replace('="', '').replace('"', '')) + $else: + $books[isbn].get(key, '') +
+
+
+ +
+ + $else: +

+ $_("No valid books with ISBNs found to import.") +

+ + $if books_wo_isbns: +
+

$_("Books Missing ISBNs")

+ +

+ $_("Note:") + $_("Unfortunately, these books do not have ISBN codes and currently we can't import these.") +

+ +
+ + + + $for key in keys: + + + + + + + $for id in books_wo_isbns: + + $for key in keys: + + + + +
$key$_("Action")
+ $if key == 'My Rating': + $('-' if str(books_wo_isbns[id].get('My Rating', '')) == '0' else books_wo_isbns[id].get('My Rating', '')) + $elif key == 'Shelves': + $ shelves_raw = books_wo_isbns[id].get('Bookshelves', '') + $(", ".join([s.strip() for s in str(shelves_raw).split(",") if s.strip()])) + $elif key in ('ISBN', 'ISBN13'): + $(str(books_wo_isbns[id].get(key, '')).replace('="', '').replace('"', '')) + $else: + $books_wo_isbns[id].get(key, '-') + + + $_("Search OL") + +
+
+
- - $ keys = ['ISBN', 'Title', 'My Rating', 'Exclusive Shelf', 'Date Read'] -
- - - - - $for key in keys: - - - - - - - $for isbn in books: - - - $for key in keys: - - - $for id in books_wo_isbns: - - - $for key in keys: - - - - - -
- - $key
- $ dict = {'ISBN':'', 'Title':'', 'My Rating':'', 'Exclusive Shelf':'', 'Date Read':''} - $ k = ['ISBN', 'My Rating', 'Exclusive Shelf', 'Date Read'] - $for key in k: - $ dict[key] = books[isbn].get(key) - - $books[isbn].get(key)
- - $if books_wo_isbns[id].get(key) != '=""': - $books_wo_isbns[id].get(key) -
+ +
+ + + + diff --git a/static/css/page-user.css b/static/css/page-user.css index 49c343d91d2..2834ad0366f 100644 --- a/static/css/page-user.css +++ b/static/css/page-user.css @@ -109,36 +109,38 @@ th.toggle-all { width: 100%; } -.import-table { - border: 2px; +/* Goodreads Imports Page */ +.my-imports { + padding: var(--spacing-page-gutter); +} + +.table-responsive { + overflow-x: auto; + width: 100%; + padding-bottom: var(--spacing-section-gap); } -.import-table th { - padding: 5px 10px; + +.import-table { + border-collapse: collapse; + border: var(--border-table); } + +.import-table th, .import-table td { - padding: 5px 10px; + padding: var(--spacing-inset-sm) var(--spacing-inset-md); + border-bottom: var(--border-table); } + .import-table thead th { - position: sticky; - top: 0; background-color: var(--white); } -.import-table tbody { - width: 100%; -} + .import-table tbody tr { background-color: var(--white); } -.my-imports { - width: 100%; -} -.import-export { - display: flex; - flex-direction: column; -} -.import-export__section { - padding: 0 10px; -} + +/* .import-export {} +.import-export__section {} */ #myProgress { margin-top: 5px;