Migrate your reading data between Storygraph and Goodreads. Makes it possible to import from Storygraph in to bookhive.buzz

fix issue with empty fields

Changed files
+277 -162
storygraph-to-goodreads
+4 -1
.gitignore
··· 19 19 .DS_Store 20 20 21 21 # Node modules (if any) 22 - node_modules/ 22 + node_modules/ 23 + 24 + # test data 25 + testdata/
+66
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + This is a web service that converts reading data between Storygraph and Goodreads CSV formats, hosted on Val Town. It provides bidirectional conversion with optional Goodreads API enrichment. 8 + 9 + ## Development Commands 10 + 11 + **Quality Check (run before deployment):** 12 + ```bash 13 + deno task quality 14 + ``` 15 + This runs formatting, linting with fixes, type checking, and all tests. 16 + 17 + **Deploy to Val Town:** 18 + ```bash 19 + deno task deploy 20 + ``` 21 + Runs quality checks first, then pushes to Val Town. 22 + 23 + **Individual Commands:** 24 + - `deno task test` - Run all tests with necessary permissions 25 + - `deno task fmt` - Format code 26 + - `deno task lint` - Lint and fix issues 27 + - `deno task check` - Type check all TypeScript files 28 + 29 + **Run a single test:** 30 + ```bash 31 + deno test --allow-net --allow-read --allow-import backend/utils/converter.test.ts 32 + ``` 33 + 34 + ## Architecture 35 + 36 + The codebase follows a clean separation pattern: 37 + 38 + - **backend/**: Hono server and business logic 39 + - `index.ts`: Main entry point, handles HTTP routes and SSE streaming 40 + - `utils/`: Core conversion logic, file validation, and Goodreads API integration 41 + 42 + - **frontend/**: React SPA with no build step 43 + - Direct ESM imports from esm.sh 44 + - TailwindCSS via Twind 45 + - Server-Sent Events for progress tracking 46 + 47 + - **shared/**: TypeScript interfaces used by both frontend and backend 48 + 49 + ## Key Technical Considerations 50 + 51 + **Platform:** This runs on Val Town using Deno (not Node.js). Use Val Town's utilities like `serveFile`, `readFile`, and blob storage. 52 + 53 + **Testing:** All business logic has comprehensive unit tests. Tests use dependency injection patterns - external services are mocked. Test files are co-located with source files (`.test.ts`). 54 + 55 + **Security:** The file validator enforces strict limits on file types, sizes (1MB max), and content patterns. All user input is validated before processing. 56 + 57 + **API Integration:** Goodreads API calls are rate-limited and cached. The enricher gracefully handles API failures and continues with basic conversion. 58 + 59 + **Progress Tracking:** Long-running conversions use Server-Sent Events to stream progress updates to the frontend. 60 + 61 + ## Val Town Specifics 62 + 63 + - Use `@std/` for Deno standard library imports 64 + - Blob storage is used for temporary file handling with 24-hour TTL 65 + - The cleanup cron job runs daily to remove expired files 66 + - Deploy with `vt push` after running quality checks
+51 -87
storygraph-to-goodreads/backend/index.ts
··· 5 5 import { 6 6 convertGoodreadsToStorygraph, 7 7 convertStorygraphToGoodreads, 8 - convertStorygraphToGoodreadsEnriched, 9 8 generateCSV, 10 9 parseCSV, 11 10 } from "./utils/converter.ts"; ··· 39 38 const formData = await c.req.formData(); 40 39 const file = formData.get("file") as File; 41 40 const direction = formData.get("direction") as ConversionDirection; 42 - const enrichWithGoodreads = formData.get("enrichWithGoodreads") === "true"; 43 41 44 42 if (!file) { 45 43 return c.json({ error: "No file provided" }, 400); ··· 76 74 let filename: string; 77 75 78 76 if (direction === "storygraph-to-goodreads") { 79 - if (enrichWithGoodreads) { 80 - console.log("🔍 Using Goodreads enrichment for better matching..."); 81 - const goodreadsBooks = await convertStorygraphToGoodreadsEnriched( 82 - inputData, 83 - ); 84 - convertedData = goodreadsBooks.map((book) => 85 - book as unknown as Record<string, string> 86 - ); 87 - filename = "goodreads_library_export_enriched.csv"; 88 - } else { 89 - const goodreadsBooks = convertStorygraphToGoodreads(inputData); 90 - convertedData = goodreadsBooks.map((book) => 91 - book as unknown as Record<string, string> 92 - ); 93 - filename = "goodreads_library_export.csv"; 94 - } 77 + console.log("🔍 Using Goodreads enrichment for better matching..."); 78 + const goodreadsBooks = await convertStorygraphToGoodreads( 79 + inputData, 80 + ); 81 + convertedData = goodreadsBooks.map((book) => 82 + book as unknown as Record<string, string> 83 + ); 84 + filename = "goodreads_library_export.csv"; 95 85 headers = [ 96 86 "Book Id", 97 87 "Title", ··· 168 158 filename, 169 159 createdAt: new Date().toISOString(), 170 160 direction, 171 - enriched: enrichWithGoodreads, 172 161 }), 173 162 ); 174 163 ··· 177 166 downloadUrl: `/api/download/${blobKey}`, 178 167 filename, 179 168 recordCount: convertedData.length, 180 - enriched: enrichWithGoodreads, 181 169 }); 182 170 } catch (error) { 183 171 console.error("Conversion error:", error); ··· 194 182 const formData = await c.req.formData(); 195 183 const file = formData.get("file") as File; 196 184 const direction = formData.get("direction") as ConversionDirection; 197 - const enrichWithGoodreads = formData.get("enrichWithGoodreads") === "true"; 198 185 199 186 if (!file) { 200 187 return c.json({ error: "No file provided" }, 400); ··· 263 250 let filename: string; 264 251 265 252 if (direction === "storygraph-to-goodreads") { 266 - if (enrichWithGoodreads) { 267 - await stream.writeSSE({ 268 - data: JSON.stringify({ 269 - event: "enrichment-start", 270 - stage: "enriching", 271 - message: 272 - "Starting Goodreads enrichment for better BookHive compatibility...", 273 - totalBooks: inputData.length, 274 - id: eventId++, 275 - }), 276 - }); 277 - 278 - // Use enrichment with progress callback 279 - const { enrichStorygraphWithGoodreads } = await import( 280 - "./utils/goodreads-enricher.ts" 281 - ); 282 - const enrichedBooks = await enrichStorygraphWithGoodreads( 283 - inputData, 284 - async (current, total, bookTitle) => { 285 - await stream.writeSSE({ 286 - data: JSON.stringify({ 287 - event: "enrichment-progress", 288 - stage: "enriching", 289 - message: `Enriching book ${current}/${total}`, 290 - currentBook: bookTitle, 291 - progress: Math.round((current / total) * 100), 292 - current, 293 - total, 294 - id: eventId++, 295 - }), 296 - }); 297 - }, 298 - ); 253 + await stream.writeSSE({ 254 + data: JSON.stringify({ 255 + event: "enrichment-start", 256 + stage: "enriching", 257 + message: 258 + "Starting Goodreads enrichment for better BookHive compatibility...", 259 + totalBooks: inputData.length, 260 + id: eventId++, 261 + }), 262 + }); 299 263 300 - await stream.writeSSE({ 301 - data: JSON.stringify({ 302 - event: "enrichment-complete", 303 - stage: "converting", 304 - message: 305 - "Goodreads enrichment completed, converting to Goodreads format...", 306 - id: eventId++, 307 - }), 308 - }); 264 + // Use enrichment with progress callback 265 + const enrichedBooks = await convertStorygraphToGoodreads( 266 + inputData, 267 + async (current, total, bookTitle) => { 268 + await stream.writeSSE({ 269 + data: JSON.stringify({ 270 + event: "enrichment-progress", 271 + stage: "enriching", 272 + message: `Enriching book ${current}/${total}`, 273 + currentBook: bookTitle, 274 + progress: Math.round((current / total) * 100), 275 + current, 276 + total, 277 + id: eventId++, 278 + }), 279 + }); 280 + }, 281 + ); 309 282 310 - // Convert enriched books to Goodreads format 311 - convertedData = enrichedBooks.map((book) => 312 - book as unknown as Record<string, string> 313 - ); 314 - filename = "goodreads_library_export_enriched.csv"; 315 - successCount = enrichedBooks.length; 316 - } else { 317 - await stream.writeSSE({ 318 - data: JSON.stringify({ 319 - event: "conversion-start", 320 - stage: "converting", 321 - message: "Converting to Goodreads format...", 322 - id: eventId++, 323 - }), 324 - }); 283 + await stream.writeSSE({ 284 + data: JSON.stringify({ 285 + event: "enrichment-complete", 286 + stage: "converting", 287 + message: 288 + "Goodreads enrichment completed, converting to Goodreads format...", 289 + id: eventId++, 290 + }), 291 + }); 325 292 326 - const goodreadsBooks = convertStorygraphToGoodreads(inputData); 327 - convertedData = goodreadsBooks.map((book) => 328 - book as unknown as Record<string, string> 329 - ); 330 - filename = "goodreads_library_export.csv"; 331 - successCount = goodreadsBooks.length; 332 - } 293 + // Convert enriched books to Goodreads format 294 + convertedData = enrichedBooks.map((book) => 295 + book as unknown as Record<string, string> 296 + ); 297 + filename = "goodreads_library_export.csv"; 298 + successCount = enrichedBooks.length; 333 299 334 300 headers = [ 335 301 "Book Id", ··· 427 393 filename, 428 394 createdAt: new Date().toISOString(), 429 395 direction, 430 - enriched: enrichWithGoodreads, 431 396 }), 432 397 ); 433 398 ··· 443 408 totalBooks: inputData.length, 444 409 successCount, 445 410 failedCount: failedBooks.length, 446 - enriched: enrichWithGoodreads, 447 411 }, 448 412 failedBooks, 449 413 id: eventId++,
+73 -6
storygraph-to-goodreads/backend/utils/converter.test.ts
··· 4 4 } from "https://deno.land/std@0.208.0/assert/mod.ts"; 5 5 import { 6 6 convertGoodreadsToStorygraph, 7 - convertStorygraphToGoodreads, 7 + convertStorygraphToGoodreadsSimple, 8 8 generateCSV, 9 9 parseCSV, 10 10 parseISBN, ··· 97 97 }, 98 98 ]; 99 99 100 - const result = convertStorygraphToGoodreads(storygraphBooks); 100 + const result = convertStorygraphToGoodreadsSimple(storygraphBooks); 101 101 102 102 assertEquals(result.length, 1); 103 103 assertEquals(result[0]["Title"], "The Martian"); ··· 127 127 }, 128 128 ]; 129 129 130 - const result = convertStorygraphToGoodreads(storygraphBooks); 130 + const result = convertStorygraphToGoodreadsSimple(storygraphBooks); 131 131 132 132 assertEquals(result[0]["Author"], "Terry Pratchett"); 133 133 assertEquals(result[0]["Additional Authors"], "Neil Gaiman, Someone Else"); ··· 144 144 }, 145 145 ]; 146 146 147 - const result = convertStorygraphToGoodreads(storygraphBooks); 147 + const result = convertStorygraphToGoodreadsSimple(storygraphBooks); 148 148 149 149 assertEquals(result[0]["Exclusive Shelf"], "to-read"); 150 150 assertEquals(result[0]["Date Read"], ""); ··· 161 161 }, 162 162 ]; 163 163 164 - const result = convertStorygraphToGoodreads(storygraphBooks); 164 + const result = convertStorygraphToGoodreadsSimple(storygraphBooks); 165 165 166 166 assertEquals(result[0]["Exclusive Shelf"], "currently-reading"); 167 167 }); ··· 284 284 }); 285 285 }); 286 286 287 + Deno.test("convertStorygraphToGoodreads - should map all fields correctly", () => { 288 + const storygraphBooks = [ 289 + { 290 + Title: "Test Book", 291 + Authors: "Primary Author", 292 + Contributors: "Contributor One", 293 + "ISBN/UID": "9781234567890", 294 + Format: "paperback", 295 + "Read Status": "read", 296 + "Date Added": "2023/01/15", 297 + "Last Date Read": "2023/02/01", 298 + "Dates Read": "2023/02/01", 299 + "Read Count": "2", 300 + "Star Rating": "4.5", 301 + Review: "Great book!", 302 + "Owned?": "Yes", 303 + }, 304 + ]; 305 + 306 + const result = convertStorygraphToGoodreadsSimple(storygraphBooks); 307 + 308 + assertEquals(result.length, 1); 309 + const book = result[0]; 310 + 311 + // Core identification 312 + assertEquals(book["Book Id"], "1"); 313 + assertEquals(book["Title"], "Test Book"); 314 + assertEquals(book["Author"], "Primary Author"); 315 + assertEquals(book["Author l-f"], ""); // Empty as expected 316 + assertEquals(book["Additional Authors"], "Contributor One"); 317 + 318 + // ISBN fields 319 + assertEquals(book["ISBN"], "123456789X"); // ISBN-10 converted from ISBN-13 (X = check digit 10) 320 + assertEquals(book["ISBN13"], "9781234567890"); 321 + 322 + // Ratings 323 + assertEquals(book["My Rating"], "4.5"); 324 + assertEquals(book["Average Rating"], ""); // Empty as expected 325 + 326 + // Publication info (empty as expected) 327 + assertEquals(book["Publisher"], ""); 328 + assertEquals(book["Number of Pages"], ""); 329 + assertEquals(book["Year Published"], ""); 330 + assertEquals(book["Original Publication Year"], ""); 331 + 332 + // Format 333 + assertEquals(book["Binding"], "Paperback"); 334 + 335 + // Dates 336 + assertEquals(book["Date Read"], "2023/02/01"); 337 + assertEquals(book["Date Added"], "2023/01/15"); 338 + 339 + // Shelves and status 340 + assertEquals(book["Bookshelves"], "read"); 341 + assertEquals(book["Bookshelves with positions"], ""); // Empty as expected 342 + assertEquals(book["Exclusive Shelf"], "read"); 343 + 344 + // Reviews and notes 345 + assertEquals(book["My Review"], "Great book!"); 346 + assertEquals(book["Spoiler"], ""); // Empty as expected 347 + assertEquals(book["Private Notes"], ""); // Empty as expected 348 + 349 + // Ownership and reading 350 + assertEquals(book["Read Count"], "2"); 351 + assertEquals(book["Owned Copies"], "1"); // "Yes" -> "1" 352 + }); 353 + 287 354 Deno.test("convertStorygraphToGoodreads - should handle missing optional fields", () => { 288 355 const storygraphBooks = [ 289 356 { ··· 293 360 }, 294 361 ]; 295 362 296 - const result = convertStorygraphToGoodreads(storygraphBooks); 363 + const result = convertStorygraphToGoodreadsSimple(storygraphBooks); 297 364 298 365 assertEquals(result[0]["Title"], "Minimal Book"); 299 366 assertEquals(result[0]["Author"], "Minimal Author");
+62 -49
storygraph-to-goodreads/backend/utils/converter.ts
··· 145 145 } 146 146 } 147 147 148 - export function convertStorygraphToGoodreads( 148 + export function convertGoodreadsToStorygraph( 149 + goodreadsBooks: Record<string, string>[], 150 + ): StorygraphBook[] { 151 + return goodreadsBooks.map((book) => { 152 + const authors = [book.Author, book["Additional Authors"]] 153 + .filter(Boolean) 154 + .join(", "); 155 + 156 + const { isbn } = parseISBN(book.ISBN13 || book.ISBN); 157 + 158 + return { 159 + "Title": book.Title || "", 160 + "Authors": authors, 161 + "Contributors": "", // Empty as specified 162 + "ISBN/UID": isbn, 163 + "Format": mapFormat(book.Binding, "goodreads-to-storygraph"), 164 + "Read Status": book["Exclusive Shelf"] || "to-read", 165 + "Date Added": convertDate(book["Date Added"], "storygraph"), 166 + "Last Date Read": convertDate(book["Date Read"], "storygraph"), 167 + "Dates Read": convertDate(book["Date Read"], "storygraph"), 168 + "Read Count": book["Read Count"] || "0", 169 + "Moods": "", // Empty as specified 170 + "Pace": "", // Empty as specified 171 + "Character- or Plot-Driven?": "", // Empty as specified 172 + "Strong Character Development?": "", // Empty as specified 173 + "Loveable Characters?": "", // Empty as specified 174 + "Diverse Characters?": "", // Empty as specified 175 + "Flawed Characters?": "", // Empty as specified 176 + "Star Rating": book["My Rating"] || "0", 177 + "Review": book["My Review"] || "", 178 + "Content Warnings": "", // Empty as specified 179 + "Content Warning Description": "", // Empty as specified 180 + "Tags": "", // Empty as specified 181 + "Owned?": "No", // Empty as specified 182 + }; 183 + }); 184 + } 185 + 186 + // Simple sync version for testing (no enrichment) 187 + export function convertStorygraphToGoodreadsSimple( 149 188 storygraphBooks: Record<string, string>[], 150 189 ): GoodreadsBook[] { 151 190 return storygraphBooks.map((book, index) => { ··· 161 200 162 201 const { isbn, isbn13 } = parseISBN(book["ISBN/UID"]); 163 202 203 + // Map Storygraph's Owned? field to Goodreads format 204 + const ownedCopies = book["Owned?"]?.toLowerCase() === "yes" ? "1" : "0"; 205 + 206 + // Use Read Status for Bookshelves 207 + const readStatus = book["Read Status"] || "to-read"; 208 + 164 209 return { 165 210 "Book Id": (index + 1).toString(), 166 211 "Title": book.Title || "", ··· 172 217 "My Rating": book["Star Rating"] || "0", 173 218 "Average Rating": "", // Empty as specified 174 219 "Publisher": "", // Empty as specified 175 - "Binding": mapFormat(book.Format, "storygraph-to-goodreads"), 220 + "Binding": mapFormat(book.Format || "", "storygraph-to-goodreads"), 176 221 "Number of Pages": "", // Empty as specified 177 222 "Year Published": "", // Empty as specified 178 223 "Original Publication Year": "", // Empty as specified 179 - "Date Read": convertDate(book["Last Date Read"], "goodreads"), 180 - "Date Added": convertDate(book["Date Added"], "goodreads"), 181 - "Bookshelves": "", // Empty as specified 224 + "Date Read": convertDate(book["Last Date Read"] || "", "goodreads"), 225 + "Date Added": convertDate(book["Date Added"] || "", "goodreads"), 226 + "Bookshelves": readStatus, 182 227 "Bookshelves with positions": "", // Empty as specified 183 - "Exclusive Shelf": book["Read Status"] || "to-read", 228 + "Exclusive Shelf": readStatus, 184 229 "My Review": book.Review || "", 185 230 "Spoiler": "", // Empty as specified 186 231 "Private Notes": "", // Empty as specified 187 232 "Read Count": book["Read Count"] || "0", 188 - "Owned Copies": "0", // Empty as specified 233 + "Owned Copies": ownedCopies, 189 234 }; 190 235 }); 191 236 } 192 237 193 - export function convertGoodreadsToStorygraph( 194 - goodreadsBooks: Record<string, string>[], 195 - ): StorygraphBook[] { 196 - return goodreadsBooks.map((book) => { 197 - const authors = [book.Author, book["Additional Authors"]] 198 - .filter(Boolean) 199 - .join(", "); 200 - 201 - const { isbn } = parseISBN(book.ISBN13 || book.ISBN); 202 - 203 - return { 204 - "Title": book.Title || "", 205 - "Authors": authors, 206 - "Contributors": "", // Empty as specified 207 - "ISBN/UID": isbn, 208 - "Format": mapFormat(book.Binding, "goodreads-to-storygraph"), 209 - "Read Status": book["Exclusive Shelf"] || "to-read", 210 - "Date Added": convertDate(book["Date Added"], "storygraph"), 211 - "Last Date Read": convertDate(book["Date Read"], "storygraph"), 212 - "Dates Read": convertDate(book["Date Read"], "storygraph"), 213 - "Read Count": book["Read Count"] || "0", 214 - "Moods": "", // Empty as specified 215 - "Pace": "", // Empty as specified 216 - "Character- or Plot-Driven?": "", // Empty as specified 217 - "Strong Character Development?": "", // Empty as specified 218 - "Loveable Characters?": "", // Empty as specified 219 - "Diverse Characters?": "", // Empty as specified 220 - "Flawed Characters?": "", // Empty as specified 221 - "Star Rating": book["My Rating"] || "0", 222 - "Review": book["My Review"] || "", 223 - "Content Warnings": "", // Empty as specified 224 - "Content Warning Description": "", // Empty as specified 225 - "Tags": "", // Empty as specified 226 - "Owned?": "No", // Empty as specified 227 - }; 228 - }); 229 - } 230 - 231 - export async function convertStorygraphToGoodreadsEnriched( 238 + export async function convertStorygraphToGoodreads( 232 239 storygraphBooks: Record<string, string>[], 233 240 onProgress?: (current: number, total: number, book: string) => void, 234 241 ): Promise<GoodreadsBook[]> { ··· 256 263 : []; 257 264 const additionalAuthors = [...originalAuthors.slice(1), ...contributors] 258 265 .filter(Boolean); 266 + 267 + // Map Storygraph's Owned? field to Goodreads format 268 + const ownedCopies = book["Owned?"]?.toLowerCase() === "yes" ? "1" : "0"; 269 + 270 + // Use Read Status for Bookshelves 271 + const readStatus = book["Read Status"] || "to-read"; 259 272 260 273 return { 261 274 "Book Id": book["Book Id"] || "", // Use Goodreads Book ID if available ··· 275 288 "Original Publication Year": "", // Still empty as we don't get this from Goodreads search 276 289 "Date Read": convertDate(book["Last Date Read"] || "", "goodreads"), 277 290 "Date Added": convertDate(book["Date Added"] || "", "goodreads"), 278 - "Bookshelves": "", 291 + "Bookshelves": readStatus, 279 292 "Bookshelves with positions": "", 280 - "Exclusive Shelf": book["Read Status"] || "to-read", 293 + "Exclusive Shelf": readStatus, 281 294 "My Review": book.Review || "", 282 295 "Spoiler": "", 283 296 "Private Notes": "", 284 297 "Read Count": book["Read Count"] || "0", 285 - "Owned Copies": "0", 298 + "Owned Copies": ownedCopies, 286 299 }; 287 300 }); 288 301 }
+16 -11
storygraph-to-goodreads/backend/utils/goodreads-enricher.ts
··· 121 121 const normalizedTitle = normalizeTitle(book.Title || ""); 122 122 const normalizedAuthor = normalizeAuthor(book.Authors || ""); 123 123 124 - // Create base enriched book with normalized data 124 + // Create base enriched book preserving ALL original Storygraph data 125 125 const enrichedBook: Record<string, string> = { 126 - ...book, 126 + ...book, // Keep all original fields including Title, Authors, Star Rating, etc. 127 127 "Book Id": "", // Leave empty as requested 128 - "Title": normalizedTitle, 129 - "Author": normalizedAuthor, 130 128 "Author l-f": "", // Will be filled if we get Goodreads data 131 129 "Additional Authors": "", // Will be filled if we get Goodreads data 132 130 }; ··· 135 133 const goodreadsResult = await searchGoodreads(normalizedTitle); 136 134 137 135 if (goodreadsResult) { 138 - // CRITICAL: Use the FULL title (with series info) to match BookHive's rawTitle field 139 - // BookHive stores rawTitle = result.title (full) and title = result.bookTitleBare (clean) 140 - // For CSV imports to match, we need to use the FULL title that BookHive stores as rawTitle 136 + // ONLY update the specific fields we need from Goodreads 137 + // This preserves all original Storygraph data while adding Goodreads enrichment 141 138 enrichedBook["Book Id"] = goodreadsResult.bookId || ""; 142 - enrichedBook["Title"] = goodreadsResult.title; // Use FULL title (matches BookHive's rawTitle) 143 - enrichedBook["Author"] = goodreadsResult.author.name || normalizedAuthor; // Use Goodreads author 139 + enrichedBook["Title"] = goodreadsResult.title; // Use FULL title with series info 140 + enrichedBook["Author"] = goodreadsResult.author.name || book.Authors || ""; // Use Goodreads author 144 141 enrichedBook["Average Rating"] = goodreadsResult.avgRating || ""; 145 142 enrichedBook["Number of Pages"] = goodreadsResult.numPages?.toString() || 146 143 ""; ··· 161 158 }`, 162 159 ); 163 160 console.log( 164 - ` 📚 Using FULL title for BookHive compatibility: "${goodreadsResult.title}"`, 161 + ` 📚 Preserved all Storygraph data: Rating=${ 162 + book["Star Rating"] 163 + }, Status=${book["Read Status"]}, Owned=${book["Owned?"]}`, 165 164 ); 166 165 } else { 166 + // No Goodreads match - just normalize the title and author 167 + enrichedBook["Book Id"] = ""; 168 + enrichedBook["Title"] = normalizedTitle; 169 + enrichedBook["Author"] = normalizedAuthor; 170 + enrichedBook["Author l-f"] = ""; 171 + enrichedBook["Additional Authors"] = ""; 167 172 console.log( 168 - `📝 Using normalized data for "${book.Title}" → "${normalizedTitle}"`, 173 + `📝 No Goodreads match for "${book.Title}" - using normalized title: "${normalizedTitle}"`, 169 174 ); 170 175 } 171 176
+5 -8
storygraph-to-goodreads/frontend/components/App.tsx
··· 89 89 const formData = new FormData(); 90 90 formData.append("file", file); 91 91 formData.append("direction", direction); 92 - formData.append( 93 - "enrichWithGoodreads", 94 - (direction === "storygraph-to-goodreads").toString(), 95 - ); 96 92 97 93 const response = await fetch("/api/convert-stream", { 98 94 method: "POST", ··· 431 427 )} 432 428 </div> 433 429 434 - {conversionState.stats.enriched && ( 430 + {direction === "storygraph-to-goodreads" && ( 435 431 <div className="enrichment-badge"> 436 432 🔍 Enhanced with Goodreads data for better BookHive 437 433 compatibility ··· 552 548 you own your data. 553 549 </p> 554 550 <p style={{ color: "#666", lineHeight: "1.6" }}> 555 - The Goodreads enrichment feature dramatically improves import 556 - success rates by normalizing book titles and fetching proper 557 - Goodreads Book IDs that other platforms can recognize. 551 + Storygraph conversions are automatically enhanced with Goodreads 552 + data to dramatically improve import success rates by normalizing 553 + book titles and fetching proper Goodreads Book IDs that other 554 + platforms can recognize. 558 555 </p> 559 556 </div> 560 557