+4
-1
.gitignore
+4
-1
.gitignore
+66
CLAUDE.md
+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
+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
+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
+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
+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
+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