Skip to content
Merged
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
7 changes: 4 additions & 3 deletions openlibrary/plugins/openlibrary/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,11 @@ jQuery(function () {
.then((module) => new module.LazyThingPreview().init());
}

// Enhance patron export buttons on /account/import
if (location.pathname === '/account/import') {
// Disable data export buttons on form submit
const patronImportForms = document.querySelectorAll('.patron-export-form')
if (patronImportForms.length) {
import(/* webpackChunkName: "patron-exports" */ './patron_exports')
.then(module => module.initPatronExportButtons());
.then(module => module.initPatronExportForms(patronImportForms));
}

const $observationModalLinks = $('.observations-modal-link');
Expand Down
150 changes: 16 additions & 134 deletions openlibrary/plugins/openlibrary/js/patron_exports.js
Original file line number Diff line number Diff line change
@@ -1,144 +1,26 @@
/**
* Enhances export buttons on /account/import so that when an export starts:
* - The clicked button shows "Downloading..."
* - The button is disabled and styled as unavailable (grey)
* - Subsequent clicks are prevented while the download is in progress
* Switches the given button to a disabled state.
*
* Implementation detail: We use fetch() to retrieve the CSV as a Blob so we
* can reliably detect completion across browsers (some do not fire iframe
* load events when Content-Disposition: attachment is used). Once the blob is
* received, we trigger a programmatic download and then restore button state.
* @param {HTMLElement} buttonElement
*/

function getSubmitButton(formElement) {
// Each export form has a single submit input
return formElement.querySelector('input[type="submit"], button[type="submit"]');
}

function disableButton(buttonElement) {
if (!buttonElement) return;
buttonElement.dataset.originalValue = buttonElement.value || buttonElement.textContent || '';
if (buttonElement.tagName === 'INPUT') {
buttonElement.value = 'Downloading...';
} else {
buttonElement.textContent = 'Downloading...';
}
buttonElement.setAttribute('disabled', 'true');
buttonElement.setAttribute('aria-disabled', 'true');
buttonElement.classList.remove('cta-btn--available');
buttonElement.classList.add('cta-btn--unavailable');
// Show loading affordance consistent with other unavailable actions
buttonElement.classList.add('cta-btn--unavailable--load');
}

function enableButton(buttonElement) {
if (!buttonElement) return;
const original = buttonElement.dataset.originalValue || '';
if (buttonElement.tagName === 'INPUT') {
buttonElement.value = original;
} else {
buttonElement.textContent = original;
}
buttonElement.removeAttribute('disabled');
buttonElement.setAttribute('aria-disabled', 'false');
buttonElement.classList.remove('cta-btn--unavailable');
buttonElement.classList.remove('cta-btn--unavailable--load');
buttonElement.classList.add('cta-btn--available');
delete buttonElement.dataset.originalValue;
}

function buildGetUrlFromForm(formElement) {
const action = formElement.getAttribute('action') || '';
const params = new URLSearchParams(new FormData(formElement));
const query = params.toString();
return query ? `${action}?${query}` : action;
}

function parseFilenameFromContentDisposition(headerValue) {
if (!headerValue) return null;
// RFC 5987 filename*=
const starMatch = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
if (starMatch && starMatch[1]) {
try {
return decodeURIComponent(starMatch[1]);
} catch (_) {
return starMatch[1];
}
}
// Basic filename="..."
const plainMatch = /filename="?([^";]+)"?/i.exec(headerValue);
return plainMatch && plainMatch[1] ? plainMatch[1] : null;
}

async function fetchAndDownload(url) {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
const error = new Error(`Export request failed with status ${response.status}`);
error.status = response.status;
throw error;
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition') || response.headers.get('content-disposition');
const fallbackName = 'OpenLibrary_Export.csv';
const filename = parseFilenameFromContentDisposition(contentDisposition) || fallbackName;
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(objectUrl);
if (a.parentNode) a.parentNode.removeChild(a);
}, 0);
}

export function initPatronExportButtons() {
// Guard: only on the import page
if (location.pathname !== '/account/import') return;

const exportForms = document.querySelectorAll('form[action="/account/export"][method="GET" i]');
if (!exportForms.length) return;

exportForms.forEach((form) => {
const submitButton = getSubmitButton(form);
if (!submitButton) return;

// Prevent double-binding
if (form.dataset.patronExportBound === 'true') return;
form.dataset.patronExportBound = 'true';

form.addEventListener('submit', (event) => {
// If already disabled, block to prevent duplicates
if (submitButton.hasAttribute('disabled')) {
event.preventDefault();
return;
}

event.preventDefault();
/**
* Adds `submit` listeners for the given form elements.
*
* When any of the given forms are submitted, the form's
* submit button is disabled.
*
* @param {NodeList<HTMLFormElement>} elems
*/
export function initPatronExportForms(elems) {
elems.forEach((form) => {
const submitButton = form.querySelector('input[type=submit]')
form.addEventListener('submit', () => {
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

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

Inconsistent quote style. The codebase predominantly uses single quotes for strings. Change "submit" to 'submit' for consistency.

Copilot uses AI. Check for mistakes.
disableButton(submitButton);
const startMs = performance.now();
const url = buildGetUrlFromForm(form);
fetchAndDownload(url)
.catch(() => {
// Swallow errors but restore button;
})
.finally(() => {
// Ensure state stays visible for at least a short time
const minDurationMs = 500;
const elapsed = performance.now() - startMs;
const remaining = Math.max(0, minDurationMs - elapsed);
setTimeout(() => enableButton(submitButton), remaining);
});
});

// Also handle direct button clicks that might bypass submit event ordering
submitButton.addEventListener('click', (event) => {
if (submitButton.hasAttribute('disabled')) {
event.preventDefault();
}
});
});
})
})
}


10 changes: 5 additions & 5 deletions openlibrary/templates/account/import.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h3>$_("Import from Goodreads")</h3>
<h3>$_("Export your Reading Log")</h3>
<p>$_("Download a copy of your reading log.") <a href="/help/faq/reading-log.en#reading-log">$_("What is a reading log?")</a></p>
<form method="GET" action="/account/export"
enctype="multipart/form-data" class="olform olform--decoration">
enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
<input type="hidden" name="type" value="reading_log">
<div>
<input type="submit" class="cta-btn cta-btn--available cta-btn--small" value="$_('Download (.csv format)')" data-ol-link-track="PatronExports|ReadingLog">
Expand All @@ -34,7 +34,7 @@ <h3>$_("Export your Reading Log")</h3>
<h3>$_("Export your book notes")</h3>
<p>$_("Download a copy of your book notes.") <a href="/help/faq/book-notes">$_("What are book notes?")</a></p>
<form method="GET" action="/account/export"
enctype="multipart/form-data" class="olform olform--decoration">
enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
<input type="hidden" name="type" value="book_notes">
<div>
<input type="submit" class="cta-btn cta-btn--available cta-btn--small" value="$_('Download (.csv format)')" data-ol-link-track="PatronExports|BookNotes">
Expand All @@ -45,7 +45,7 @@ <h3>$_("Export your book notes")</h3>
<h3>$_("Export your reviews")</h3>
<p>$_("Download a copy of your review tags.") <a href="/help/faq/reviews#book-tags">$_("What are review tags?")</a></p>
<form method="GET" action="/account/export"
enctype="multipart/form-data" class="olform olform--decoration">
enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
<input type="hidden" name="type" value="reviews">
<div>
<input type="submit" class="cta-btn cta-btn--available cta-btn--small" value="$_('Download (.csv format)')" data-ol-link-track="PatronExports|ReviewTags">
Expand All @@ -56,7 +56,7 @@ <h3>$_("Export your reviews")</h3>
<h3>$_("Export your list overview")</h3>
<p>$_("Download a summary of your lists and their contents.") <a href="/help/faq/reading-log.en#lists">$_("What are lists?")</a></p>
<form method="GET" action="/account/export"
enctype="multipart/form-data" class="olform olform--decoration">
enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
<input type="hidden" name="type" value="lists">
<div>
<input type="submit" class="cta-btn cta-btn--available cta-btn--small" value="$_('Download (.csv format)')" data-ol-link-track="PatronExports|ListsSummary">
Expand All @@ -67,7 +67,7 @@ <h3>$_("Export your list overview")</h3>
<h3>$_("Export your star ratings")</h3>
<p>$_("Download a copy of your star ratings.") <a href="/help/faq/reviews#star-ratings">$_("What are star ratings?")</a></p>
<form method="GET" action="/account/export"
enctype="multipart/form-data" class="olform olform--decoration">
enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
<input type="hidden" name="type" value="ratings">
<div>
<input type="submit" class="cta-btn cta-btn--available cta-btn--small" value="$_('Download (.csv format)')" data-ol-link-track="PatronExports|StarRatings">
Expand Down
Loading