Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/nperez0111/bookhive. Track your books, share your shelves, see what others are reading bookhive.buzz
atproto bluesky books bookshelf goodreads management-system
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482import { parse } from "csv-parse";import { BookStatus } from "../types";
export interface GoodreadsBook { bookId: string; title: string; author: string; authorLastFirst: string; additionalAuthors: string[]; isbn: string; isbn13: string; myRating: number; averageRating: number; publisher: string; binding: string; numberOfPages: number; yearPublished: number; originalPublicationYear: number; dateRead: Date | null; dateAdded: Date; bookshelves: string[]; bookshelvesWithPositions: string; exclusiveShelf: string; myReview: string; spoiler: boolean; privateNotes: string; readCount: number; ownedCopies: number;}
export interface StorygraphBook { title: string; authors: string; contributors: string; isbn: string; format: string; readStatus: string; dateAdded: Date | null; lastDateRead: Date | null; datesRead: string; readCount: number; moods: string; pace: string; characterOrPlot: string; strongCharacterDevelopment: string; loveableCharacters: string; diverseCharacters: string; flawedCharacters: string; starRating: number; review: string; contentWarnings: string; contentWarningDescription: string; tags: string; owned: boolean;}
export function getStorygraphCsvParser() { const parser = parse({ skip_empty_lines: true, trim: true, columns: true, // Use first line as column headers // Add error handling options skip_records_with_error: true, skip_records_with_empty_values: false, relax_column_count: true, // Allow inconsistent column counts relax_quotes: true, // Be more lenient with quotes cast: (value: string, { column }): any => { // Handle empty values if (value === "" || value === '""') { if (column === "Star Rating") return 0; if (column === "Read Count") return 0; if (column === "Owned?") return false; return null; }
// Remove surrounding quotes if present if (value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1); }
// Handle different columns appropriately switch (column) { case "Star Rating": // StoryGraph uses 0-5 scale, multiply by 2 to match Goodreads 0-10 scale internally return parseFloat(value) || 0; case "Read Count": return parseInt(value) || 0; case "Date Added": case "Last Date Read": // StoryGraph uses YYYY/MM/DD format return value && value !== '""' ? new Date(value) : null; case "Owned?": return value.toLowerCase() === "yes"; default: return value || ""; } }, });
return new TransformStream<Uint8Array, StorygraphBook>({ transform(chunk, controller) { try { parser.write(chunk);
// Process any records that are ready let record: any; while ((record = parser.read())) { // Validate the record before enqueueing if (record && record["Title"] && record["Authors"]) { // Map CSV columns to StorygraphBook interface const storygraphBook: StorygraphBook = { title: record["Title"] || "", authors: record["Authors"] || "", contributors: record["Contributors"] || "", isbn: record["ISBN/UID"] || "", format: record["Format"] || "", readStatus: record["Read Status"] || "", dateAdded: record["Date Added"], lastDateRead: record["Last Date Read"], datesRead: record["Dates Read"] || "", readCount: record["Read Count"] || 0, moods: record["Moods"] || "", pace: record["Pace"] || "", characterOrPlot: record["Character- or Plot-Driven?"] || "", strongCharacterDevelopment: record["Strong Character Development?"] || "", loveableCharacters: record["Loveable Characters?"] || "", diverseCharacters: record["Diverse Characters?"] || "", flawedCharacters: record["Flawed Characters?"] || "", starRating: record["Star Rating"] || 0, review: record["Review"] || "", contentWarnings: record["Content Warnings"] || "", contentWarningDescription: record["Content Warning Description"] || "", tags: record["Tags"] || "", owned: record["Owned?"] || false, }; controller.enqueue(storygraphBook); } else { console.warn("Skipping invalid StoryGraph record:", record); } } } catch (error) { console.warn("Error processing StoryGraph CSV chunk:", error); // Continue processing other chunks instead of crashing } }, flush(controller) { try { parser.end();
// Get any remaining records let record: any; while ((record = parser.read())) { // Validate the record before enqueueing if (record && record["Title"] && record["Authors"]) { const storygraphBook: StorygraphBook = { title: record["Title"] || "", authors: record["Authors"] || "", contributors: record["Contributors"] || "", isbn: record["ISBN/UID"] || "", format: record["Format"] || "", readStatus: record["Read Status"] || "", dateAdded: record["Date Added"], lastDateRead: record["Last Date Read"], datesRead: record["Dates Read"] || "", readCount: record["Read Count"] || 0, moods: record["Moods"] || "", pace: record["Pace"] || "", characterOrPlot: record["Character- or Plot-Driven?"] || "", strongCharacterDevelopment: record["Strong Character Development?"] || "", loveableCharacters: record["Loveable Characters?"] || "", diverseCharacters: record["Diverse Characters?"] || "", flawedCharacters: record["Flawed Characters?"] || "", starRating: record["Star Rating"] || 0, review: record["Review"] || "", contentWarnings: record["Content Warnings"] || "", contentWarningDescription: record["Content Warning Description"] || "", tags: record["Tags"] || "", owned: record["Owned?"] || false, }; controller.enqueue(storygraphBook); } else { console.warn("Skipping invalid StoryGraph record during flush:", record); } } } catch (error) { console.warn("Error during StoryGraph CSV parser flush:", error); // Don't crash, just log the error } }, });}
export function getGoodreadsCsvParser() { const columns = [ "bookId", "title", "author", "authorLastFirst", "additionalAuthors", "isbn", "isbn13", "myRating", "averageRating", "publisher", "binding", "numberOfPages", "yearPublished", "originalPublicationYear", "dateRead", "dateAdded", "bookshelves", "bookshelvesWithPositions", "exclusiveShelf", "myReview", "spoiler", "privateNotes", "readCount", "ownedCopies", ]; const columnsWithoutAverageRating = columns.filter((column) => column !== "averageRating");
const parser = parse({ skip_empty_lines: true, trim: true, columns: (headers: string[]) => headers.includes("Average Rating") ? columns : columnsWithoutAverageRating, cast: (value: string, { column }): any => { // First handle the quoted values if (value.startsWith('="') && value.endsWith('"')) { value = value.slice(2, -1); }
// Handle different columns appropriately switch (column) { case "bookId": return value; case "myRating": case "numberOfPages": case "yearPublished": case "originalPublicationYear": case "readCount": case "ownedCopies": return parseInt(value) || 0; case "averageRating": return parseFloat(value) || 0; case "dateRead": case "dateAdded": return value ? new Date(value) : null; case "additionalAuthors": case "bookshelves": return value ? value.split(", ").filter(Boolean) : []; case "spoiler": return value.toLowerCase() === "true"; default: return value; } }, // Add error handling options skip_records_with_error: true, skip_records_with_empty_values: false, relax_column_count: true, // Allow inconsistent column counts relax_quotes: true, // Be more lenient with quotes });
return new TransformStream<Uint8Array, GoodreadsBook>({ transform(chunk, controller) { try { parser.write(chunk);
// Process any records that are ready let record: GoodreadsBook; while ((record = parser.read() as GoodreadsBook)) { // Validate the record before enqueueing if (record && record.title && record.author) { record.averageRating ??= 0; controller.enqueue(record); } else { console.warn("Skipping invalid Goodreads record:", record); } } } catch (error) { console.warn("Error processing CSV chunk:", error); // Continue processing other chunks instead of crashing } }, flush(controller) { try { parser.end();
// Get any remaining records let record: GoodreadsBook; while ((record = parser.read() as GoodreadsBook)) { // Validate the record before enqueueing if (record && record.title && record.author) { record.averageRating ??= 0; controller.enqueue(record); } else { console.warn("Skipping invalid Goodreads record during flush:", record); } } } catch (error) { console.warn("Error during CSV parser flush:", error); // Don't crash, just log the error } }, });}
export interface HardcoverBook { title: string; author: string; series: string; status: BookStatus; privacy: string; hardcoverBookId: string; hardcoverEditionId: string; isbn10: string; isbn13: string; asin: string; media: string; countryCode: string; languageCode: string; binding: string; pages: number; durationInSeconds: number; publishDate: Date | null; publisher: string; genres: string; moods: string; tags: string; contentWarnings: string; lists: string; dateAdded: Date | null; dateStarted: Date | null; dateFinished: Date | null; rating: number; review: string; reviewContainsSpoilers: boolean; sponsoredReview: boolean; reviewDate: Date | null; reviewUrl: string; reviewMediaUrl: string; privateNotes: string; owned: boolean; compilation: boolean; reviewSlate: string;}
function get(record: Record<string, string>, key: string): string { return record[key] || "";}
function parseDate(date: string): Date | null { const newDate = new Date(date); if (isNaN(newDate.getTime())) return null; return newDate;}
function parseBoolean(input: string): boolean { return input.toLowerCase() === "true";}
function parseStatus(status: string, dateFinished: Date | null): BookStatus { switch (status.toLowerCase()) { case "read": case BookStatus.finished: return BookStatus.finished; case "currently reading": case BookStatus.reading: return BookStatus.reading; case "want to read": case BookStatus.wantToRead: return BookStatus.wantToRead; case "stopped": default: return dateFinished ? BookStatus.finished : BookStatus.wantToRead; }}
export function parseHardcoverRecord(record: Record<string, string>): HardcoverBook { const dateFinished = parseDate(get(record, "Date Finished")); return { title: get(record, "Title"), author: get(record, "Author"), series: get(record, "Series"), status: parseStatus(get(record, "Status"), dateFinished), privacy: get(record, "Privacy"), hardcoverBookId: get(record, "Hardcover Book ID"), hardcoverEditionId: get(record, "Hardcover Edition ID"), isbn10: get(record, "ISBN 10").replace(/[-\s]/g, ""), isbn13: get(record, "ISBN 13").replace(/[-\s]/g, ""), asin: get(record, "ASIN"), media: get(record, "Media"), countryCode: get(record, "Country Code"), languageCode: get(record, "Language Code"), binding: get(record, "Binding"), pages: parseInt(get(record, "Pages")) || 0, durationInSeconds: parseInt(get(record, "Duration in Seconds")) || 0, publishDate: parseDate(get(record, "Publish Date")), publisher: get(record, "Publisher"), genres: get(record, "Genres"), moods: get(record, "Moods"), tags: get(record, "Tags"), contentWarnings: get(record, "Content Warnings"), lists: get(record, "Lists"), dateAdded: parseDate(get(record, "Date Added")), dateStarted: parseDate(get(record, "Date Started")), dateFinished, rating: parseInt(`${parseFloat(get(record, "Rating")) * 2}`) || 0, review: get(record, "Review"), reviewContainsSpoilers: parseBoolean(get(record, "Review Contains Spoilers")), sponsoredReview: parseBoolean(get(record, "Sponsored Review")), reviewDate: parseDate(get(record, "Review Date")), reviewUrl: get(record, "Review URL"), reviewMediaUrl: get(record, "Review Media URL"), privateNotes: get(record, "Private Notes"), owned: parseBoolean(get(record, "Owned")), compilation: get(record, "Compilation").toLowerCase() === "yes", reviewSlate: get(record, "Review Slate"), };}
export function getHardcoverCsvParser() { const parser = parse({ columns: true, relax_column_count: true, relax_quotes: true, skip_empty_lines: true, skip_records_with_error: true, trim: true, // all coercion and parsing logic is handled in parseHardcoverRecord, // this is merely to ensure everything is a string without wrapping quotes cast: (value?: string): string => { if (!value) return ""; if (value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1); } return value; }, });
return new TransformStream<Uint8Array, HardcoverBook>({ transform(chunk, controller) { try { parser.write(chunk);
// Process any records that are ready let record: Record<string, string> | undefined; while ((record = parser.read())) { if (record) { const parsedRecord = parseHardcoverRecord(record); if (parsedRecord.title !== "" && parsedRecord.author !== "") { controller.enqueue(parsedRecord); continue; } } console.warn("Skipping invalid Hardcover record:", record); } } catch (error) { console.warn("Error processing CSV chunk:", error); } }, flush(controller) { try { parser.end();
// Get any remaining records let record: any; while ((record = parser.read())) { if (record && "Title" in record && "Author" in record) { controller.enqueue(parseHardcoverRecord(record)); } else { console.warn("Skipping invalid Hardcover record during flush:", record); } } } catch (error) { console.warn("Error during CSV parser flush:", error); } }, });}