]*>(.*?)<\/span>/si', $html, $matches)) {
- foreach ($matches[1] as $index => $format_asin) {
- $format_name = trim(strip_tags($matches[2][$index]));
- if ($format_asin !== $asin && !empty($format_name)) {
- $book['formats'][] = [
- 'asin' => $format_asin,
- 'format' => $format_name,
- 'url' => 'https://www.amazon.com/dp/' . $format_asin,
- ];
- }
- }
- }
-
- return $book;
- }
-
- /**
- * Get all format ASINs for a book by searching
- *
- * @param string $title Book title.
- * @param string $author Author name.
- * @return array Format => ASIN mapping.
- */
- public function get_format_asins($title, $author = '') {
- $formats = [
- 'kindle' => '',
- 'paperback' => '',
- 'hardcover' => '',
- 'audio' => '',
- ];
-
- // Search for the book
- $results = $this->search_books($title, $author);
-
- if (isset($results['error']) || empty($results)) {
- return $formats;
- }
-
- // Match results to formats
- foreach ($results as $book) {
- $book_title = strtolower($book['title'] ?? '');
- $book_format = strtolower($book['format'] ?? '');
-
- // Check if this is the same book (title match)
- similar_text(strtolower($title), $book_title, $percent);
- if ($percent < 60) {
- continue;
- }
-
- if (strpos($book_format, 'kindle') !== false || strpos($book_format, 'ebook') !== false) {
- $formats['kindle'] = $book['asin'];
- } elseif (strpos($book_format, 'paperback') !== false) {
- $formats['paperback'] = $book['asin'];
- } elseif (strpos($book_format, 'hardcover') !== false) {
- $formats['hardcover'] = $book['asin'];
- } elseif (strpos($book_format, 'audio') !== false || strpos($book_format, 'audible') !== false) {
- $formats['audio'] = $book['asin'];
- }
- }
-
- return $formats;
- }
-}
diff --git a/umbra-bookshelf/includes/books/class-umbra-authors.php b/umbra-bookshelf/includes/books/class-umbra-authors.php
index 788349c..260d302 100644
--- a/umbra-bookshelf/includes/books/class-umbra-authors.php
+++ b/umbra-bookshelf/includes/books/class-umbra-authors.php
@@ -1,6 +1,6 @@
amazon_asin) || !empty($book->isbn_ebook)) {
- $isbn = $book->amazon_asin ?: $book->isbn_ebook;
- $amazon_rating = $this->fetch_amazon_rating($isbn);
-
- // Keep last rating if fetch fails
- if ($amazon_rating === false) {
- $amazon_rating = $book->rating_amazon;
- }
+
+ // Without a recorded Goodreads ID there is nothing to look up, and no
+ // reliable way to discover one from the catalogue alone. Leave the
+ // stored rating untouched.
+ if (empty($book->goodreads_id)) {
+ return false;
}
-
- // Sync Goodreads rating
- if (!empty($book->goodreads_id)) {
- $goodreads_rating = $this->fetch_goodreads_rating($book->goodreads_id);
-
- // Keep last rating if fetch fails
- if ($goodreads_rating === false) {
- $goodreads_rating = $book->rating_goodreads;
- }
+
+ $scraper = new Umbra_Goodreads_Scraper();
+ $details = $scraper->get_book_details($book->goodreads_id);
+
+ // A transport or parse failure comes back as an 'error' key. Keep the
+ // last known rating rather than overwriting it with zero.
+ if (!is_array($details) || !empty($details['error'])) {
+ $message = (is_array($details) && !empty($details['error']))
+ ? $details['error']
+ : 'unexpected response shape';
+ error_log('Umbra Bookshelf: Goodreads fetch error - ' . $message);
+ return false;
}
-
- // Update book ratings
- $table = Umbra_Database::table('books');
+
+ $rating = !empty($details['rating']) ? (float) $details['rating'] : 0.0;
+
+ $update = [
+ 'rating_goodreads' => $rating,
+ 'last_sync' => current_time('mysql'),
+ ];
+
+ // Goodreads also reports a cover and an ISBN-13. Adopt them only where
+ // the catalogue has nothing, so local edits are never clobbered.
+ if (!empty($details['cover_image']) && empty($book->cover_image)) {
+ $update['cover_image'] = esc_url_raw($details['cover_image']);
+ }
+ if (!empty($details['isbn13']) && empty($book->isbn_ebook)) {
+ $update['isbn_ebook'] = $details['isbn13'];
+ }
+
$wpdb->update(
- $table,
- [
- 'rating_amazon' => $amazon_rating,
- 'rating_goodreads' => $goodreads_rating,
- 'last_sync' => current_time('mysql'),
- ],
+ Umbra_Database::table('books'),
+ $update,
['id' => $book_id],
- ['%f', '%f', '%s'],
+ null,
['%d']
);
-
+
return true;
}
- /**
- * Fetch Amazon rating via web scraping
- */
- private function fetch_amazon_rating($isbn) {
- $url = "https://www.amazon.com/dp/{$isbn}";
-
- $response = wp_remote_get($url, [
- 'timeout' => 15,
- 'headers' => [
- 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
- 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
- 'Accept-Language' => 'en-US,en;q=0.5',
- ],
- ]);
-
- if (is_wp_error($response)) {
- error_log('BD Bookshelf: Amazon fetch error - ' . $response->get_error_message());
- return false;
- }
-
- $body = wp_remote_retrieve_body($response);
-
- // Parse rating from HTML
- // Amazon uses data-hook="review-star-rating" or similar
- if (preg_match('/data-hook="review-star-rating"[^>]*>.*?(\d+\.?\d*)\s*out of\s*5/si', $body, $matches)) {
- return (float) $matches[1];
- }
-
- // Alternative pattern
- if (preg_match('/a-icon-alt[^>]*>(\d+\.?\d*)\s*out of\s*\d+ stars/si', $body, $matches)) {
- return (float) $matches[1];
- }
-
- return false;
- }
-
- /**
- * Fetch Goodreads rating via web scraping
- */
- private function fetch_goodreads_rating($goodreads_id) {
- $url = "https://www.goodreads.com/book/show/{$goodreads_id}";
-
- $response = wp_remote_get($url, [
- 'timeout' => 15,
- 'headers' => [
- 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
- 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
- 'Accept-Language' => 'en-US,en;q=0.5',
- ],
- ]);
-
- if (is_wp_error($response)) {
- error_log('BD Bookshelf: Goodreads fetch error - ' . $response->get_error_message());
- return false;
- }
-
- $body = wp_remote_retrieve_body($response);
-
- // Parse rating from HTML
- // Goodreads uses ratingValue in meta or specific class
- if (preg_match('/ratingValue[^>]*>(\d+\.?\d*)]*>(\d+\.?\d*) self::VERSION_OPTION,
+ 'from' => (int) get_option(self::VERSION_OPTION, 0),
+ 'books_migrated' => 0,
+ 'series_migrated' => 0,
+ 'books_legacy' => false,
+ 'series_legacy' => false,
+ 'errors' => array(),
+ );
+
+ if ($report['from'] >= self::TARGET_VERSION) {
+ $report['skipped'] = true;
+ return $report;
+ }
+
+ global $wpdb;
+
+ // ------------------------------------------------------------------
+ // Series first: books.series_id must point at already-migrated rows.
+ // ------------------------------------------------------------------
+ $series = self::table('series');
+ if (self::table_exists($series) && self::series_is_library_shape($series)) {
+ try {
+ $report['series_migrated'] = self::migrate_series($series);
+ $report['series_legacy'] = true;
+ } catch (Throwable $e) {
+ $report['errors'][] = 'series: ' . $e->getMessage();
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Books.
+ // ------------------------------------------------------------------
+ $books = self::table('books');
+ if (self::table_exists($books) && self::books_is_library_shape($books)) {
+ try {
+ $report['books_migrated'] = self::migrate_books($books);
+ $report['books_legacy'] = true;
+ } catch (Throwable $e) {
+ $report['errors'][] = 'books: ' . $e->getMessage();
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Leave the rating cache alone. bd_ratings is keyed by book_id and the
+ // migration preserves book IDs, so the rows stay valid. They are
+ // folded into the catalogue's own rating_* columns below.
+ // ------------------------------------------------------------------
+ if (!$report['errors']) {
+ self::fold_library_ratings_into_catalogue();
+
+ update_option(self::VERSION_OPTION, self::TARGET_VERSION, false);
+ $report['version'] = self::TARGET_VERSION;
+ }
+
+ if ($report['errors']) {
+ $report['version'] = $report['from']; // not advanced; retried next time
+ error_log('Umbra Bookshelf: schema migration failed - '
+ . implode('; ', $report['errors']));
+ }
+
+ return $report;
+ }
+
+ /**
+ * Fully-qualified table name.
+ *
+ * @param string $name Bare table name, e.g. 'books'.
+ * @return string
+ */
+ private static function table($name) {
+ return Umbra_Database::table($name);
+ }
+
+ /**
+ * Does the table exist?
+ *
+ * @param string $table
+ * @return bool
+ */
+ private static function table_exists($table) {
+ global $wpdb;
+ $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
+ return $found === $table;
+ }
+
+ /**
+ * Column names for a table.
+ *
+ * @param string $table
+ * @return array
+ */
+ private static function columns($table) {
+ global $wpdb;
+ return (array) $wpdb->get_col("DESCRIBE {$table}");
+ }
+
+ /**
+ * Is bd_books still in the old library shape?
+ *
+ * Library shape is identified by post_id and the absence of title.
+ *
+ * @param string $table
+ * @return bool
+ */
+ private static function books_is_library_shape($table) {
+ $cols = self::columns($table);
+ return in_array('post_id', $cols, true) && !in_array('title', $cols, true);
+ }
+
+ /**
+ * Is bd_series still in the old library shape?
+ *
+ * Library shape has slug + blurb; catalogue shape has description.
+ *
+ * @param string $table
+ * @return bool
+ */
+ private static function series_is_library_shape($table) {
+ $cols = self::columns($table);
+ return in_array('blurb', $cols, true) && !in_array('description', $cols, true);
+ }
+
+ /**
+ * Move the legacy table aside and create a canonical one.
+ *
+ * @param string $table
+ * @param string $label For logging.
+ * @return string Legacy table name.
+ */
+ private static function set_aside($table, $label) {
+ global $wpdb;
+
+ $legacy = $table . self::LEGACY_SUFFIX;
+
+ // Never clobber an existing backup from a previous attempt.
+ if (self::table_exists($legacy)) {
+ $legacy = $table . self::LEGACY_SUFFIX . '_' . gmdate('YmdHis');
+ }
+
+ $wpdb->query("RENAME TABLE {$table} TO {$legacy}");
+ error_log("Umbra Bookshelf: {$label} legacy schema set aside as {$legacy}");
+
+ return $legacy;
+ }
+
+ /**
+ * Migrate series rows.
+ *
+ * ID is preserved so books.series_id keeps resolving.
+ *
+ * @param string $table Legacy bd_series.
+ * @return int Rows copied.
+ */
+ private static function migrate_series($table) {
+ global $wpdb;
+
+ $legacy = self::set_aside($table, 'series');
+
+ // Create the canonical shape.
+ $database = new Umbra_Database();
+ $database->create_tables();
+
+ $target = self::table('series');
+ $rows = $wpdb->get_results("SELECT * FROM {$legacy}", ARRAY_A);
+ $copied = 0;
+
+ foreach ($rows as $row) {
+ $name = isset($row['name']) ? (string) $row['name'] : '';
+ if ($name === '') {
+ continue;
+ }
+
+ $cover = '';
+ if (!empty($row['cover_attachment_id'])) {
+ $cover = (string) wp_get_attachment_url((int) $row['cover_attachment_id']);
+ }
+
+ $inserted = $wpdb->insert(
+ $target,
+ array(
+ 'id' => (int) $row['id'],
+ 'name' => $name,
+ // blurb was the library's prose field for a series.
+ 'description' => isset($row['blurb']) ? (string) $row['blurb'] : '',
+ 'cover_image' => $cover,
+ 'created_at' => !empty($row['created_at']) ? $row['created_at'] : current_time('mysql'),
+ ),
+ array('%d', '%s', '%s', '%s', '%s')
+ );
+
+ if ($inserted !== false) {
+ $copied++;
+ }
+ }
+
+ return $copied;
+ }
+
+ /**
+ * Migrate book rows.
+ *
+ * The library row is a thin pointer at a WordPress post, so title, slug and
+ * body are resolved from that post. IDs are preserved, which keeps
+ * bd_book_meta, bd_reviews, bd_ratings and bd_review_shortlinks valid, and
+ * lets us point _bd_bookshelf_book_id at the catalogue row.
+ *
+ * @param string $table Legacy bd_books.
+ * @return int Rows copied.
+ */
+ private static function migrate_books($table) {
+ global $wpdb;
+
+ $legacy = self::set_aside($table, 'books');
+
+ $database = new Umbra_Database();
+ $database->create_tables();
+
+ $target = self::table('books');
+ $rows = $wpdb->get_results("SELECT * FROM {$legacy}", ARRAY_A);
+ $copied = 0;
+ $used_slugs = array();
+
+ foreach ($rows as $row) {
+ $post_id = isset($row['post_id']) ? (int) $row['post_id'] : 0;
+ $post = $post_id ? get_post($post_id) : null;
+
+ $title = $post ? (string) $post->post_title : '';
+ if ($title === '') {
+ // Nothing to name the book after; skip rather than create a
+ // row that cannot satisfy title NOT NULL meaningfully.
+ continue;
+ }
+
+ $slug = $post ? (string) $post->post_name : '';
+ $slug = $slug !== '' ? $slug : sanitize_title($title);
+
+ // Catalogue slug is NOT NULL UNIQUE.
+ $base = $slug;
+ $suffix = 1;
+ while (in_array($slug, $used_slugs, true)) {
+ $slug = $base . '-' . (++$suffix);
+ }
+ $used_slugs[] = $slug;
+
+ $book_id = isset($row['id']) ? (int) $row['id'] : 0;
+
+ // The library stored one ISBN and one ASIN with no format attached.
+ // Both land on the ebook columns, which is the least lossy guess.
+ $isbn = isset($row['isbn']) ? (string) $row['isbn'] : '';
+ $asin = isset($row['asin']) ? (string) $row['asin'] : '';
+
+ $inserted = $wpdb->insert(
+ $target,
+ array(
+ 'id' => $book_id,
+ 'title' => $title,
+ 'slug' => $slug,
+ 'description' => $post ? (string) $post->post_content : '',
+ 'cover_image' => $post ? (string) get_the_post_thumbnail_url($post_id, 'large') : '',
+ 'isbn_ebook' => $isbn,
+ 'asin_ebook' => $asin,
+ // Retained so existing Amazon product links keep resolving.
+ 'amazon_asin' => $asin,
+ 'series_id' => isset($row['series_id']) ? (int) $row['series_id'] : null,
+ 'created_at' => !empty($row['created_at']) ? $row['created_at'] : current_time('mysql'),
+ ),
+ array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s')
+ );
+
+ if ($inserted === false) {
+ continue;
+ }
+
+ $copied++;
+
+ // Record the link the library used to imply with its post_id
+ // UNIQUE column. Preserved meta key.
+ if ($post_id && $book_id) {
+ update_post_meta($post_id, '_bd_bookshelf_book_id', $book_id);
+ }
+
+ // Publish date lived only on the library row; the catalogue has no
+ // column for it, so keep it on the post for the theme to read.
+ if ($post_id && !empty($row['publish_date'])) {
+ update_post_meta($post_id, '_bd_bookshelf_publish_date', $row['publish_date']);
+ }
+ }
+
+ return $copied;
+ }
+
+ /**
+ * Copy bd_ratings values into the catalogue's own rating columns.
+ *
+ * Safe to call more than once: it only writes where the catalogue column
+ * is still at its zero default.
+ *
+ * @return void
+ */
+ public static function fold_library_ratings_into_catalogue() {
+ global $wpdb;
+
+ $ratings = $wpdb->prefix . 'bd_ratings';
+ $books = self::table('books');
+
+ if (!self::table_exists($ratings) || !self::table_exists($books)) {
+ return;
+ }
+
+ $rows = $wpdb->get_results(
+ "SELECT book_id, source, rating FROM {$ratings}", ARRAY_A
+ );
+
+ foreach ($rows as $row) {
+ $book_id = (int) $row['book_id'];
+ $rating = (float) $row['rating'];
+ if ($rating <= 0) {
+ continue;
+ }
+
+ $source = strtolower((string) $row['source']);
+
+ if (strpos($source, 'goodreads') !== false) {
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$books} SET rating_goodreads = %f
+ WHERE id = %d AND rating_goodreads = 0",
+ $rating, $book_id
+ ));
+ } elseif (strpos($source, 'amazon') !== false) {
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$books} SET rating_amazon = %f
+ WHERE id = %d AND rating_amazon = 0",
+ $rating, $book_id
+ ));
+ }
+ }
+ }
+
+ /**
+ * Human-readable status, for the admin and for tests.
+ *
+ * @return array
+ */
+ public static function status() {
+ $books = self::table('books');
+ $series = self::table('series');
+
+ return array(
+ 'version' => (int) get_option(self::VERSION_OPTION, 0),
+ 'target_version' => self::TARGET_VERSION,
+ 'books_exists' => self::table_exists($books),
+ 'books_shape' => self::table_exists($books)
+ ? (self::books_is_library_shape($books) ? 'library' : 'catalogue')
+ : 'absent',
+ 'series_exists' => self::table_exists($series),
+ 'series_shape' => self::table_exists($series)
+ ? (self::series_is_library_shape($series) ? 'library' : 'catalogue')
+ : 'absent',
+ );
+ }
+}
diff --git a/umbra-bookshelf/includes/email/class-umbra-email-admin.php b/umbra-bookshelf/includes/email/class-umbra-email-admin.php
index f08c752..7b209ca 100644
--- a/umbra-bookshelf/includes/email/class-umbra-email-admin.php
+++ b/umbra-bookshelf/includes/email/class-umbra-email-admin.php
@@ -1,6 +1,6 @@
' . esc_html__('Default: 30469651 (B_D_Murphy)', 'umbra-bookshelf') . '';
echo '';
- echo '' . esc_html__('Amazon Ratings Warning', 'umbra-bookshelf') . '
';
- echo '' . esc_html__('Warning: Scraping Amazon product pages may conflict with their Terms of Service. Use at your own risk.', 'umbra-bookshelf') . '
';
+ echo '' . esc_html__('Ratings are refreshed every 12 hours from Goodreads. Amazon ratings are not scraped.', 'umbra-bookshelf') . '
';
submit_button(esc_html__('Save Settings', 'umbra-bookshelf'), 'primary', 'bd_save_settings');
echo '